diff --git a/src/archml/cli/main.py b/src/archml/cli/main.py index 7af219d..207cb24 100644 --- a/src/archml/cli/main.py +++ b/src/archml/cli/main.py @@ -4,10 +4,11 @@ """Entry point for the ArchML command-line interface.""" import argparse +import re import sys from pathlib import Path -from archml.compiler.build import CompilerError, compile_files +from archml.compiler.build import CompilerError, SourceImportKey, compile_files from archml.validation.checks import validate from archml.workspace.config import WorkspaceConfigError, load_workspace_config @@ -144,6 +145,13 @@ def _cmd_init(args: argparse.Namespace) -> int: if not name: print("Error: mnemonic name cannot be empty.", file=sys.stderr) return 1 + if not re.match(r"^[a-z][a-z0-9_-]*$", name): + print( + f"Error: invalid mnemonic name '{name}': must start with a lowercase letter " + "followed by lowercase letters, digits, hyphens, or underscores.", + file=sys.stderr, + ) + return 1 workspace_dir = Path(args.workspace_dir).resolve() workspace_dir.mkdir(parents=True, exist_ok=True) @@ -157,7 +165,7 @@ def _cmd_init(args: argparse.Namespace) -> int: return 1 workspace_yaml.write_text( - f"build-directory: {_DEFAULT_BUILD_DIR}\nsource-imports:\n - name: {name}\n local-path: .\n", + f"name: {name}\nbuild-directory: {_DEFAULT_BUILD_DIR}\nsource-imports:\n - name: {name}\n local-path: .\n", encoding="utf-8", ) @@ -189,9 +197,6 @@ def _cmd_check(args: argparse.Namespace) -> int: directory = root workspace_yaml = directory / ".archml-workspace.yaml" - # The empty-string key represents the workspace root for non-mnemonic imports. - source_import_map: dict[str, Path] = {"": directory} - try: config = load_workspace_config(workspace_yaml) except WorkspaceConfigError as exc: @@ -201,13 +206,16 @@ def _cmd_check(args: argparse.Namespace) -> int: build_dir = directory / config.build_directory sync_dir = directory / config.remote_sync_directory + # Build the source import map: SourceImportKey(repo, mnemonic) -> absolute base path. + # Local mnemonics use config.name as repo; remote repos use "@name". + source_import_map: dict[SourceImportKey, Path] = {} + for imp in config.source_imports: if isinstance(imp, LocalPathImport): - source_import_map[imp.name] = (directory / imp.local_path).resolve() + source_import_map[SourceImportKey(config.name, imp.name)] = (directory / imp.local_path).resolve() elif isinstance(imp, GitPathImport): repo_dir = (sync_dir / imp.name).resolve() if repo_dir.exists(): - source_import_map[f"@{imp.name}"] = repo_dir remote_workspace_yaml = repo_dir / ".archml-workspace.yaml" if remote_workspace_yaml.exists(): try: @@ -215,11 +223,20 @@ def _cmd_check(args: argparse.Namespace) -> int: for remote_imp in remote_config.source_imports: if isinstance(remote_imp, LocalPathImport): mnemonic_path = (repo_dir / remote_imp.local_path).resolve() - source_import_map[f"@{imp.name}/{remote_imp.name}"] = mnemonic_path + source_import_map[SourceImportKey(f"@{imp.name}", remote_imp.name)] = mnemonic_path except WorkspaceConfigError as exc: print(f"Warning: could not load workspace config from remote '{imp.name}': {exc}") - archml_files = [f for f in directory.rglob("*.archml") if build_dir not in f.parents and sync_dir not in f.parents] + # Scan only files under local mnemonic paths (repo == config.name, i.e. not remote). + local_mnemonic_paths = {base_path for key, base_path in source_import_map.items() if key.repo == config.name} + seen_files: set[Path] = set() + archml_files: list[Path] = [] + for base_path in sorted(local_mnemonic_paths): + for f in base_path.rglob("*.archml"): + if f not in seen_files and build_dir not in f.parents and sync_dir not in f.parents: + seen_files.add(f) + archml_files.append(f) + if not archml_files: print("No .archml files found in the workspace.") return 0 diff --git a/src/archml/compiler/build.py b/src/archml/compiler/build.py index dbd3873..d9dfd4e 100644 --- a/src/archml/compiler/build.py +++ b/src/archml/compiler/build.py @@ -10,29 +10,30 @@ Two forms of cross-file import are supported: * **Local workspace imports** — ``from mnemonic/path/to/file import …`` - The first path segment is looked up as a mnemonic in the caller-supplied - *source_import_map* (``{mnemonic: absolute_base_path}``), derived from the - workspace ``source-imports`` configuration. + The first path segment is the mnemonic name. It is looked up as + ``(source_repo, mnemonic)`` in the caller-supplied *source_import_map* + (``{(repo_id, mnemonic): absolute_base_path}``), where *source_repo* is the + repository identifier of the file performing the import. -* **Remote git imports** — ``from @repo/path/to/file import …`` +* **Remote git imports** — ``from @repo/mnemonic/path/to/file import …`` The ``@repo`` prefix identifies a named Git repository configured in the - workspace. The caller-supplied *source_import_map* must contain an entry - keyed as ``"@repo"`` pointing to the locally synced directory (populated by - ``archml sync-remote``). If the key is absent, :class:`CompilerError` is - raised with a message directing the user to run ``archml sync-remote``. - -* **Remote git imports with mnemonics** — ``from @repo/mnemonic/path/to/file import …`` - When the remote repository defines its own ``source-imports`` in its - ``.archml-workspace.yaml``, those mnemonics are exposed as two-segment keys - ``"@repo/mnemonic"`` in *source_import_map*. Resolution tries the - two-segment key before falling back to the bare ``"@repo"`` key so that - mnemonic-rooted paths take precedence over literal subdirectory paths in the - repository root. + workspace. The ``mnemonic`` segment selects a named source tree within + that repository. The caller-supplied *source_import_map* must contain an + entry keyed as ``("@repo", "mnemonic")`` pointing to the locally synced + directory (populated by ``archml sync-remote``). If the key is absent, + :class:`CompilerError` is raised with a message directing the user to run + ``archml sync-remote``. + +Every source file belongs to exactly one repository, identified by the +*repo_id* of the ``(repo_id, mnemonic)`` entry whose base path contains the +file. Bare mnemonic imports (without ``@repo``) are resolved relative to the +source file's repository. """ from __future__ import annotations from pathlib import Path +from typing import NamedTuple from archml.compiler.artifact import ARTIFACT_SUFFIX, read_artifact, write_artifact from archml.compiler.parser import ParseError, parse @@ -44,6 +45,20 @@ # ############### +class SourceImportKey(NamedTuple): + """Typed key for the source-import map. + + *repo* identifies the repository: a plain workspace name (e.g. ``"myapp"``) + for local imports, or an ``@``-prefixed remote name (e.g. ``"@payments"``) + for git imports. It must never be empty. + + *mnemonic* is the named source tree within that repository. + """ + + repo: str + mnemonic: str + + class CompilerError(Exception): """Raised when the compiler encounters any unrecoverable error. @@ -58,7 +73,7 @@ def __init__(self, message: str) -> None: def compile_files( files: list[Path], build_dir: Path, - source_import_map: dict[str, Path], + source_import_map: dict[SourceImportKey, Path], ) -> dict[str, ArchFile]: """Compile a list of .archml source files. @@ -74,17 +89,19 @@ def compile_files( Args: files: Absolute paths to the .archml source files to compile. build_dir: Root directory for compiled artifacts. - source_import_map: Mapping from mnemonic names to absolute base paths. - The empty-string key ``""`` represents the workspace root (used to - resolve non-mnemonic imports and compute artifact keys for files - that do not belong to a named mnemonic). Import paths of the form - ``mnemonic/rel`` are resolved by looking up *mnemonic* in this map - and appending *rel*. + source_import_map: Mapping from :class:`SourceImportKey` pairs to + absolute base paths. Local mnemonics use the workspace name as + *repo* (e.g. ``SourceImportKey("myapp", "myapp")``); remote + repositories use their ``"@name"`` identifier + (e.g. ``SourceImportKey("@payments", "lib")``). + Import paths of the form ``mnemonic/rel`` are resolved by + looking up ``(source_repo, mnemonic)`` and appending *rel*. + Remote imports ``@repo/mnemonic/rel`` use ``("@repo", mnemonic)``. Returns: - A mapping from canonical path keys (e.g. ``"shared/types"`` or - ``"common/types"``) to their compiled :class:`~archml.model.entities.ArchFile` - models. + A mapping from canonical path keys (e.g. ``"myapp/types"`` or + ``"@payments/lib/types"``) to their compiled + :class:`~archml.model.entities.ArchFile` models. Raises: CompilerError: On parse errors, missing imports, unsupported remote @@ -93,8 +110,6 @@ def compile_files( compiled: dict[str, ArchFile] = {} in_progress: set[str] = set() for f in files: - # For top-level files, compute the key from the filesystem path. - # Dependency keys are always the import path string (passed via _key). _compile_file(f, build_dir, source_import_map, compiled, in_progress) return compiled @@ -104,44 +119,55 @@ def compile_files( # ################ -def _rel_key(source_file: Path, source_import_map: dict[str, Path]) -> str: - """Return the canonical key for a source file (path without extension). - - For files under the workspace root (``source_import_map[""]``), the key is - the relative path without the ``.archml`` suffix (e.g. ``"shared/types"``). +def _get_source_repo(source_file: Path, source_import_map: dict[SourceImportKey, Path]) -> str: + """Return the repository identifier for *source_file*. - For files under a named mnemonic base path, the key is ``"mnemonic/rel"`` - (e.g. ``"common/types"``). + Iterates over all ``(repo_id, mnemonic)`` entries and returns the + *repo_id* of the entry whose base path contains *source_file*. Raises: - CompilerError: If the file is not under any configured base path. + CompilerError: If the file is not under any configured mnemonic base path. """ - # Try the workspace root (empty-string key) first — its key has no prefix. - workspace_root = source_import_map.get("") - if workspace_root is not None: + for (repo_id, _mnemonic), base_path in source_import_map.items(): try: - rel = source_file.relative_to(workspace_root) - return str(rel.with_suffix("")).replace("\\", "/") + source_file.relative_to(base_path) + return repo_id except ValueError: - pass - - for mnemonic, base_path in source_import_map.items(): - if mnemonic == "": continue + raise CompilerError(f"Source file '{source_file}' is not under any configured mnemonic base path") + + +def _rel_key(source_file: Path, source_import_map: dict[SourceImportKey, Path]) -> str: + """Return the canonical key for a source file (path without extension). + + For local files (repo not starting with ``"@"``), the key is + ``"mnemonic/rel"`` (e.g. ``"myapp/shared/types"``). + + For remote files (repo starting with ``"@"``), the key is + ``"@repo/mnemonic/rel"`` (e.g. ``"@payments/lib/types"``). + + Raises: + CompilerError: If the file is not under any configured base path. + """ + for (repo_id, mnemonic), base_path in source_import_map.items(): try: rel = source_file.relative_to(base_path) - return f"{mnemonic}/" + str(rel.with_suffix("")).replace("\\", "/") + rel_str = str(rel.with_suffix("")).replace("\\", "/") + if not repo_id.startswith("@"): + return f"{mnemonic}/{rel_str}" + else: + return f"{repo_id}/{mnemonic}/{rel_str}" except ValueError: continue - raise CompilerError(f"Source file '{source_file}' is not under any configured source import base path") + raise CompilerError(f"Source file '{source_file}' is not under any configured mnemonic base path") def _artifact_path(key: str, build_dir: Path) -> Path: """Return the artifact path for a given canonical key. The key segments (split on ``/``) map directly to subdirectory components - under *build_dir* (e.g. ``"common/types"`` → ``build_dir/common/types.archml.json``). + under *build_dir* (e.g. ``"myapp/types"`` → ``build_dir/myapp/types.archml.json``). """ parts = key.split("/") artifact_dir = build_dir @@ -159,62 +185,70 @@ def _is_up_to_date(source_file: Path, artifact: Path) -> bool: def _resolve_import_source( import_path: str, - source_import_map: dict[str, Path], + source_import_map: dict[SourceImportKey, Path], + source_repo: str, ) -> Path: """Resolve an import path to the absolute path of the ``.archml`` source file. Args: - import_path: The raw import path string as stored in :class:`ImportDeclaration` - (e.g. ``"shared/types"``, ``"common/types"``, ``"@repo/path/to/file"``, - or ``"@repo/mnemonic/path/to/file"``). - source_import_map: Mapping from mnemonic names to base paths. The - empty-string key ``""`` is the workspace root used for - non-mnemonic imports. Remote repositories are keyed as ``"@repo"`` - and their mnemonics as ``"@repo/mnemonic"``. + import_path: The raw import path string as stored in :class:`ImportDeclaration`. + Must be one of: + - ``"mnemonic/path/to/file"`` — resolved using *source_repo* + - ``"@repo/mnemonic/path/to/file"`` — resolved using the named + remote repository and mnemonic + source_import_map: Mapping from :class:`SourceImportKey` to base paths. + source_repo: Repository identifier of the file performing the import + (workspace name for local, ``"@repo"`` for remote). Returns: Absolute path to the ``.archml`` file (which may or may not exist). Raises: - CompilerError: If *import_path* is a remote git import (starts with - ``@``) whose repository key is absent from *source_import_map*. + CompilerError: If *import_path* has an invalid format or if the + required mnemonic is absent from *source_import_map*. """ if import_path.startswith("@"): - slash_pos = import_path.find("/", 1) - if slash_pos == -1: - raise CompilerError(f"Invalid remote git import path '{import_path}': expected '@repo/path/to/file'") - # Try a two-segment key (@repo/mnemonic) first so that remote-repo - # mnemonics take precedence over literal subdirectory paths. - second_slash = import_path.find("/", slash_pos + 1) - if second_slash != -1: - two_seg_key = import_path[:second_slash] # e.g. "@payments/utils" - if two_seg_key in source_import_map: - rel = import_path[second_slash + 1 :] - return source_import_map[two_seg_key] / (rel + ".archml") - repo_key = import_path[:slash_pos] - if repo_key not in source_import_map: + # Format: @repo/mnemonic/path/to/file + slash1 = import_path.find("/", 1) + if slash1 == -1: + raise CompilerError(f"Invalid remote import '{import_path}': expected '@repo/mnemonic/path' format") + repo_id = import_path[:slash1] # e.g. "@payments" + rest = import_path[slash1 + 1 :] # e.g. "lib/types" or "lib/shared/types" + slash2 = rest.find("/") + if slash2 == -1: + raise CompilerError( + f"Invalid remote import '{import_path}': expected '@repo/mnemonic/path' format " + "(missing path component after mnemonic)" + ) + mnemonic = rest[:slash2] # e.g. "lib" + path = rest[slash2 + 1 :] # e.g. "types" or "shared/types" + key = SourceImportKey(repo_id, mnemonic) + if key not in source_import_map: raise CompilerError( - f"Remote git import '{import_path}': repository '{repo_key}' not found in workspace. " - "Run 'archml sync-remote' to download remote repositories." + f"Remote import '{import_path}': mnemonic '{mnemonic}' in repository '{repo_id}' " + "not found in workspace. Run 'archml sync-remote' to download remote repositories." ) - rel = import_path[slash_pos + 1 :] - return source_import_map[repo_key] / (rel + ".archml") - slash_pos = import_path.find("/") - if slash_pos != -1: - first_segment = import_path[:slash_pos] - if first_segment in source_import_map: - rel = import_path[slash_pos + 1 :] - return source_import_map[first_segment] / (rel + ".archml") - workspace_root = source_import_map.get("") - if workspace_root is None: - raise CompilerError(f"Cannot resolve import '{import_path}': no workspace root configured in source_import_map") - return workspace_root / (import_path + ".archml") + return source_import_map[key] / (path + ".archml") + + # Format: mnemonic/path/to/file + slash1 = import_path.find("/") + if slash1 == -1: + raise CompilerError( + f"Invalid import '{import_path}': expected 'mnemonic/path' format " + "(imports must start with a mnemonic name followed by a path)" + ) + mnemonic = import_path[:slash1] # e.g. "mylib" + path = import_path[slash1 + 1 :] # e.g. "types" or "shared/types" + key = SourceImportKey(source_repo, mnemonic) + if key not in source_import_map: + raise CompilerError(f"Import '{import_path}': mnemonic '{mnemonic}' not found in workspace configuration") + return source_import_map[key] / (path + ".archml") def _compile_file( source_file: Path, build_dir: Path, - source_import_map: dict[str, Path], + source_import_map: dict[SourceImportKey, Path], compiled: dict[str, ArchFile], in_progress: set[str], *, @@ -225,16 +259,13 @@ def _compile_file( Args: source_file: Absolute path to the .archml file to compile. build_dir: Root directory for compiled artifacts. - source_import_map: Mapping from mnemonic names to base paths for - cross-workspace import resolution. The empty-string key ``""`` - is the workspace root. + source_import_map: Mapping from :class:`SourceImportKey` to base paths. compiled: Accumulator mapping already-compiled canonical keys to models. in_progress: Set of canonical keys currently being compiled (cycle guard). - _key: Optional pre-computed canonical key. When provided (always the case - for dependency files resolved via ``_resolve_import_source``), the key - is used directly without inferring it from the filesystem path. This - prevents ambiguity when a mnemonic base path is nested inside the - workspace root. + _key: Optional pre-computed canonical key. When provided (always the + case for dependency files resolved via ``_resolve_import_source``), + the key is used directly without inferring it from the filesystem + path. Returns: The compiled :class:`~archml.model.entities.ArchFile` for *source_file*. @@ -243,6 +274,7 @@ def _compile_file( CompilerError: On any compilation failure. """ key = _key if _key is not None else _rel_key(source_file, source_import_map) + source_repo = _get_source_repo(source_file, source_import_map) if key in compiled: return compiled[key] @@ -260,7 +292,7 @@ def _compile_file( deps_valid = True for imp in arch_file.imports: try: - dep_source = _resolve_import_source(imp.source_path, source_import_map) + dep_source = _resolve_import_source(imp.source_path, source_import_map, source_repo) except CompilerError: deps_valid = False break @@ -271,7 +303,7 @@ def _compile_file( # Recursively ensure all dependencies are also loaded into the # result map so callers see the full transitive closure. for imp in arch_file.imports: - dep_source = _resolve_import_source(imp.source_path, source_import_map) + dep_source = _resolve_import_source(imp.source_path, source_import_map, source_repo) _compile_file( dep_source, build_dir, @@ -299,7 +331,7 @@ def _compile_file( # Recursively compile all imported dependencies. resolved_imports: dict[str, ArchFile] = {} for imp in arch_file.imports: - dep_source = _resolve_import_source(imp.source_path, source_import_map) + dep_source = _resolve_import_source(imp.source_path, source_import_map, source_repo) if not dep_source.exists(): raise CompilerError( f"Dependency '{imp.source_path}' of '{source_file}' not found (expected '{dep_source}')" diff --git a/src/archml/workspace/config.py b/src/archml/workspace/config.py index 93ac746..299e1c3 100644 --- a/src/archml/workspace/config.py +++ b/src/archml/workspace/config.py @@ -3,15 +3,18 @@ """Workspace configuration loading and data models.""" +import re from pathlib import Path import yaml -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator # ############### # Public Interface # ############### +_MNEMONIC_RE = re.compile(r"^[a-z][a-z0-9_-]*$") + class WorkspaceConfigError(Exception): """Raised when the workspace configuration file is invalid or unreadable.""" @@ -25,6 +28,17 @@ class LocalPathImport(BaseModel): name: str local_path: str = Field(alias="local-path") + @field_validator("name") + @classmethod + def validate_mnemonic_name(cls, v: str) -> str: + """Validate mnemonic: lowercase letter, then alphanumeric/dash/underscore.""" + if not _MNEMONIC_RE.match(v): + raise ValueError( + f"Invalid mnemonic name '{v}': must start with a lowercase letter " + "followed by lowercase letters, digits, hyphens, or underscores (no slashes)" + ) + return v + class GitPathImport(BaseModel): """A source import resolved from a git repository.""" @@ -35,6 +49,17 @@ class GitPathImport(BaseModel): git_repository: str = Field(alias="git-repository") revision: str + @field_validator("name") + @classmethod + def validate_mnemonic_name(cls, v: str) -> str: + """Validate repo name: lowercase letter, then alphanumeric/dash/underscore.""" + if not _MNEMONIC_RE.match(v): + raise ValueError( + f"Invalid repo name '{v}': must start with a lowercase letter " + "followed by lowercase letters, digits, hyphens, or underscores (no slashes)" + ) + return v + SourceImport = LocalPathImport | GitPathImport @@ -44,9 +69,34 @@ class WorkspaceConfig(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) + name: str build_directory: str = Field(alias="build-directory") remote_sync_directory: str = Field(alias="remote-sync-directory", default=".archml-remotes") - source_imports: list[LocalPathImport | GitPathImport] = Field(alias="source-imports", default_factory=list) + source_imports: list[LocalPathImport | GitPathImport] = Field( + alias="source-imports", + min_length=1, + ) + + @field_validator("name") + @classmethod + def validate_workspace_name(cls, v: str) -> str: + """Validate workspace name: lowercase letter, then alphanumeric/dash/underscore.""" + if not _MNEMONIC_RE.match(v): + raise ValueError( + f"Invalid workspace name '{v}': must start with a lowercase letter " + "followed by lowercase letters, digits, hyphens, or underscores" + ) + return v + + @model_validator(mode="after") + def validate_unique_import_names(self) -> "WorkspaceConfig": + """Ensure all source import names are unique within this workspace.""" + seen: set[str] = set() + for imp in self.source_imports: + if imp.name in seen: + raise ValueError(f"Duplicate source import name: '{imp.name}'") + seen.add(imp.name) + return self WORKSPACE_CONFIG_FILENAME = ".archml-workspace.yaml" diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 8f802be..86db75a 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -37,7 +37,7 @@ def test_init_creates_workspace_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyP def test_init_workspace_yaml_has_correct_content(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """init writes source-import mapping with the given mnemonic into the YAML config.""" + """init writes workspace name, source-import mapping, and build-directory into the YAML config.""" monkeypatch.setattr(sys, "argv", ["archml", "init", "myrepo", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: main() @@ -62,7 +62,9 @@ def test_init_creates_workspace_dir_if_not_exists(tmp_path: Path, monkeypatch: p def test_init_fails_if_workspace_yaml_already_exists(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """init exits with error code 1 when .archml-workspace.yaml already exists.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / ".archml-workspace.yaml").write_text( + "name: src\nbuild-directory: .archml-build\nsource-imports:\n - name: src\n local-path: .\n" + ) monkeypatch.setattr(sys, "argv", ["archml", "init", "myrepo", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: main() @@ -77,6 +79,30 @@ def test_init_fails_if_name_is_empty(tmp_path: Path, monkeypatch: pytest.MonkeyP assert exc_info.value.code == 1 +def test_init_fails_if_name_has_invalid_format( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """init exits with error code 1 when the mnemonic name has an invalid format.""" + monkeypatch.setattr(sys, "argv", ["archml", "init", "MyRepo", str(tmp_path)]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "Error" in captured.err + + +def test_init_fails_if_name_has_slash( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """init exits with error code 1 when the mnemonic name contains a slash.""" + monkeypatch.setattr(sys, "argv", ["archml", "init", "my/repo", str(tmp_path)]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "Error" in captured.err + + def test_init_succeeds_if_dir_exists_without_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """init succeeds when the directory exists but has no .archml-workspace.yaml.""" monkeypatch.setattr(sys, "argv", ["archml", "init", "myrepo", str(tmp_path)]) @@ -87,10 +113,12 @@ def test_init_succeeds_if_dir_exists_without_yaml(tmp_path: Path, monkeypatch: p # -------- check tests -------- +_MINIMAL_WORKSPACE = "name: src\nbuild-directory: .archml-build\nsource-imports:\n - name: src\n local-path: .\n" + def test_check_with_no_archml_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """check exits with code 0 and reports no files when workspace has none.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: main() @@ -103,7 +131,7 @@ def test_check_with_valid_archml_file( capsys: pytest.CaptureFixture[str], ) -> None: """check discovers .archml files, compiles them, and reports success.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) (tmp_path / "arch.archml").write_text("component MyComponent {}\n") monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: @@ -120,7 +148,7 @@ def test_check_reports_compile_error( capsys: pytest.CaptureFixture[str], ) -> None: """check exits with code 1 when a .archml file has a parse error.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) (tmp_path / "bad.archml").write_text("component {}") # missing name monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: @@ -136,7 +164,7 @@ def test_check_reports_validation_errors( capsys: pytest.CaptureFixture[str], ) -> None: """check exits with code 1 when business validation finds errors.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) # Connection cycle: A -> B -> A (inline components inside system) (tmp_path / "cycle.archml").write_text( "interface I { field v: Int }\n" @@ -159,7 +187,7 @@ def test_check_reports_validation_warnings( capsys: pytest.CaptureFixture[str], ) -> None: """check exits with code 0 but prints warnings for isolated components.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) # An isolated component triggers a warning but not an error. (tmp_path / "isolated.archml").write_text("component Isolated {}\n") monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)]) @@ -175,7 +203,9 @@ def test_check_uses_workspace_yaml_build_dir( monkeypatch: pytest.MonkeyPatch, ) -> None: """check uses the build-directory from .archml-workspace.yaml when present.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: custom-build\n") + (tmp_path / ".archml-workspace.yaml").write_text( + "name: src\nbuild-directory: custom-build\nsource-imports:\n - name: src\n local-path: .\n" + ) (tmp_path / "arch.archml").write_text("interface Signal { field v: Int }\ncomponent A { provides Signal }\n") monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: @@ -212,7 +242,7 @@ def test_check_autodetects_workspace_in_parent_directory( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """check finds the workspace by walking up from a subdirectory.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) (tmp_path / "arch.archml").write_text("component MyComponent {}\n") subdir = tmp_path / "src" / "components" subdir.mkdir(parents=True) @@ -239,7 +269,7 @@ def test_check_reports_parse_error( capsys: pytest.CaptureFixture[str], ) -> None: """check exits with code 1 and prints error message on parse failure.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) (tmp_path / "bad.archml").write_text("component {}\n") # missing name monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: @@ -260,7 +290,10 @@ def test_check_with_workspace_yaml_and_local_source_import( (lib_dir / "iface.archml").write_text("interface MyIface { field v: Int }\n") (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\nsource-imports:\n - name: mylib\n local-path: lib\n" + "name: myproject\nbuild-directory: build\n" + "source-imports:\n" + " - name: src\n local-path: .\n" + " - name: mylib\n local-path: lib\n" ) (tmp_path / "app.archml").write_text("from mylib/iface import MyIface\ncomponent C { requires MyIface }\n") @@ -293,7 +326,9 @@ def test_check_excludes_build_directory_from_scan( capsys: pytest.CaptureFixture[str], ) -> None: """Artifacts in the build directory are not re-scanned as source files.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n") + (tmp_path / ".archml-workspace.yaml").write_text( + "name: src\nbuild-directory: build\nsource-imports:\n - name: src\n local-path: .\n" + ) # Place a valid source file and compile it once to create the artifact. (tmp_path / "comp.archml").write_text("component Good {}\n") @@ -310,6 +345,21 @@ def test_check_excludes_build_directory_from_scan( assert "No issues found." in captured.out +def test_check_fails_if_workspace_config_has_no_source_imports( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """check exits with code 1 when workspace config has no source-imports.""" + (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n") + monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "Error" in captured.err + + # -------- serve tests -------- @@ -323,7 +373,7 @@ def test_serve_fails_if_no_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyP def test_serve_autodetects_workspace_in_parent_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """serve finds the workspace by walking up from a subdirectory.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) subdir = tmp_path / "src" / "components" subdir.mkdir(parents=True) monkeypatch.setattr(sys, "argv", ["archml", "serve", str(subdir)]) @@ -348,7 +398,7 @@ def test_serve_fails_if_directory_does_not_exist(tmp_path: Path, monkeypatch: py def test_serve_launches_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """serve creates and runs the web app when workspace exists.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) monkeypatch.setattr(sys, "argv", ["archml", "serve", str(tmp_path)]) mock_app = MagicMock() with ( @@ -362,7 +412,7 @@ def test_serve_launches_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> def test_serve_custom_host_and_port(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """serve passes custom host and port to the app.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: .archml-build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) monkeypatch.setattr( sys, "argv", @@ -404,7 +454,7 @@ def test_sync_remote_autodetects_workspace_in_parent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """sync-remote finds the workspace by walking up from a subdirectory.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) subdir = tmp_path / "src" / "components" subdir.mkdir(parents=True) monkeypatch.setattr(sys, "argv", ["archml", "sync-remote", str(subdir)]) @@ -417,7 +467,10 @@ def test_sync_remote_autodetects_workspace_in_parent( def test_sync_remote_no_git_imports(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """sync-remote exits 0 when no git imports are configured.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n") + (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") + (tmp_path / ".archml-workspace.yaml").write_text( + "name: src\nbuild-directory: build\nsource-imports:\n - name: src\n local-path: .\n" + ) monkeypatch.setattr(sys, "argv", ["archml", "sync-remote", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: main() @@ -427,7 +480,7 @@ def test_sync_remote_no_git_imports(tmp_path: Path, monkeypatch: pytest.MonkeyPa def test_sync_remote_fails_without_lockfile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """sync-remote exits 1 when the lockfile is missing.""" (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" " - name: payments\n" " git-repository: https://example.com/payments\n" @@ -444,7 +497,7 @@ def test_sync_remote_fails_if_repo_not_in_lockfile( ) -> None: """sync-remote exits 1 when a configured repo is missing from the lockfile.""" (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" " - name: payments\n" " git-repository: https://example.com/payments\n" @@ -464,7 +517,7 @@ def test_sync_remote_skips_repo_already_at_pinned_commit( ) -> None: """sync-remote skips a repo that is already at the pinned commit.""" (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" " - name: payments\n" " git-repository: https://example.com/payments\n" @@ -493,7 +546,7 @@ def test_sync_remote_clones_repo( ) -> None: """sync-remote calls clone_at_commit when repo is not at the pinned commit.""" (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" " - name: payments\n" " git-repository: https://example.com/payments\n" @@ -528,7 +581,7 @@ def test_sync_remote_reports_error_on_clone_failure( from archml.workspace.git_ops import GitError (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" " - name: payments\n" " git-repository: https://example.com/payments\n" @@ -556,7 +609,7 @@ def test_sync_remote_reports_error_on_clone_failure( def test_sync_remote_uses_custom_sync_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """sync-remote uses the remote-sync-directory from workspace config.""" (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "remote-sync-directory: custom-remotes\n" "source-imports:\n" " - name: lib\n" @@ -609,7 +662,7 @@ def test_update_remote_autodetects_workspace_in_parent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """update-remote finds the workspace by walking up from a subdirectory.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n") + (tmp_path / ".archml-workspace.yaml").write_text(_MINIMAL_WORKSPACE) subdir = tmp_path / "src" / "components" subdir.mkdir(parents=True) monkeypatch.setattr(sys, "argv", ["archml", "update-remote", str(subdir)]) @@ -622,7 +675,10 @@ def test_update_remote_autodetects_workspace_in_parent( def test_update_remote_no_git_imports(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """update-remote exits 0 when no git imports are configured.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n") + (tmp_path / ".archml-workspace").write_text("[workspace]\nversion = '1'\n") + (tmp_path / ".archml-workspace.yaml").write_text( + "name: src\nbuild-directory: build\nsource-imports:\n - name: src\n local-path: .\n" + ) monkeypatch.setattr(sys, "argv", ["archml", "update-remote", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: main() @@ -633,7 +689,7 @@ def test_update_remote_creates_lockfile_from_branch(tmp_path: Path, monkeypatch: """update-remote creates a lockfile by resolving branch revisions.""" resolved_commit = "c" * 40 (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" " - name: payments\n" " git-repository: https://example.com/payments\n" @@ -661,7 +717,7 @@ def test_update_remote_creates_lockfile_from_branch(tmp_path: Path, monkeypatch: def test_update_remote_pins_commit_hash_without_network(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """update-remote does not call git for revisions that are already commit hashes.""" (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" " - name: lib\n" " git-repository: https://example.com/lib\n" @@ -687,7 +743,7 @@ def test_update_remote_updates_existing_lockfile(tmp_path: Path, monkeypatch: py old_commit = "0" * 40 new_commit = "1" * 40 (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" " - name: payments\n" " git-repository: https://example.com/payments\n" @@ -721,7 +777,7 @@ def test_update_remote_exits_1_on_resolution_failure( from archml.workspace.git_ops import GitError (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" " - name: lib\n" " git-repository: https://example.com/lib\n" @@ -741,19 +797,27 @@ def test_update_remote_exits_1_on_resolution_failure( def test_check_command_uses_synced_remote_repos( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - """check includes synced remote repos in the source import map when they exist.""" + """check includes synced remote repos' mnemonics in the source import map when they exist.""" remote_dir = tmp_path / ".archml-remotes" / "payments" - remote_dir.mkdir(parents=True) - (remote_dir / "api.archml").write_text("interface PaymentAPI { field amount: Decimal }\n") + remote_api_dir = remote_dir / "api" + remote_api_dir.mkdir(parents=True) + (remote_api_dir / "types.archml").write_text("interface PaymentAPI { field amount: Decimal }\n") + # Remote repo defines its own workspace config with the "api" mnemonic. + (remote_dir / ".archml-workspace.yaml").write_text( + "name: payments\nbuild-directory: build\nsource-imports:\n - name: api\n local-path: api\n" + ) (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" + " - name: src\n local-path: .\n" " - name: payments\n" " git-repository: https://example.com/payments\n" " revision: main\n" ) - (tmp_path / "app.archml").write_text("from @payments/api import PaymentAPI\ncomponent C { requires PaymentAPI }\n") + (tmp_path / "app.archml").write_text( + "from @payments/api/types import PaymentAPI\ncomponent C { requires PaymentAPI }\n" + ) monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: main() @@ -773,12 +837,13 @@ def test_check_command_loads_remote_repo_mnemonics( # Remote repo has its own .archml-workspace.yaml defining a "lib" mnemonic. (remote_dir / ".archml-workspace.yaml").write_text( - "build-directory: build\nsource-imports:\n - name: lib\n local-path: src/lib\n" + "name: payments\nbuild-directory: build\nsource-imports:\n - name: lib\n local-path: src/lib\n" ) (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" + " - name: src\n local-path: .\n" " - name: payments\n" " git-repository: https://example.com/payments\n" " revision: main\n" @@ -801,18 +866,19 @@ def test_check_command_warns_on_invalid_remote_workspace_yaml( """check emits a warning when a remote repo's .archml-workspace.yaml is invalid.""" remote_dir = tmp_path / ".archml-remotes" / "payments" remote_dir.mkdir(parents=True) - (remote_dir / "api.archml").write_text("interface PaymentAPI { field amount: Decimal }\n") - # Malformed remote workspace config + # Malformed remote workspace config — no mnemonics loaded for @payments. (remote_dir / ".archml-workspace.yaml").write_text("bad yaml: [unterminated\n") (tmp_path / ".archml-workspace.yaml").write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" + " - name: src\n local-path: .\n" " - name: payments\n" " git-repository: https://example.com/payments\n" " revision: main\n" ) - (tmp_path / "app.archml").write_text("from @payments/api import PaymentAPI\ncomponent C { requires PaymentAPI }\n") + # Local file with no imports from @payments — compilation succeeds despite bad remote config. + (tmp_path / "app.archml").write_text("component C {}\n") monkeypatch.setattr(sys, "argv", ["archml", "check", str(tmp_path)]) with pytest.raises(SystemExit) as exc_info: main() diff --git a/tests/compiler/test_build.py b/tests/compiler/test_build.py index 3a3cb5e..02376d6 100644 --- a/tests/compiler/test_build.py +++ b/tests/compiler/test_build.py @@ -63,17 +63,17 @@ def test_compiles_simple_file(self, tmp_path: Path) -> None: component A { provides Signal } """, ) - result = compile_files([src / "simple.archml"], build, {"": src}) - assert "simple" in result - assert result["simple"].components[0].name == "A" + result = compile_files([src / "simple.archml"], build, {("", "app"): src}) + assert "app/simple" in result + assert result["app/simple"].components[0].name == "A" def test_artifact_written_to_build_dir(self, tmp_path: Path) -> None: src = tmp_path / "src" build = tmp_path / "build" source = src / "x.archml" _write(source, "component C {}") - compile_files([source], build, {"": src}) - artifact = _artifact(build, "x") + compile_files([source], build, {("", "app"): src}) + artifact = _artifact(build, "app/x") assert artifact.exists() def test_artifact_can_be_read_back(self, tmp_path: Path) -> None: @@ -81,8 +81,8 @@ def test_artifact_can_be_read_back(self, tmp_path: Path) -> None: build = tmp_path / "build" source = src / "x.archml" _write(source, "component MyComp {}") - compile_files([source], build, {"": src}) - artifact = _artifact(build, "x") + compile_files([source], build, {("", "app"): src}) + artifact = _artifact(build, "app/x") af = read_artifact(artifact) assert af.components[0].name == "MyComp" @@ -96,8 +96,8 @@ def test_compiles_file_with_enum_and_type(self, tmp_path: Path) -> None: type Point { field x: Int field y: Int } """, ) - result = compile_files([src / "types.archml"], build, {"": src}) - af = result["types"] + result = compile_files([src / "types.archml"], build, {("", "app"): src}) + af = result["app/types"] assert af.enums[0].name == "Color" assert af.types[0].name == "Point" @@ -114,11 +114,11 @@ def test_cache_hit_skips_recompile(self, tmp_path: Path) -> None: source = src / "x.archml" _write(source, "component A {}") # mtime set 2s in the past - compile_files([source], build, {"": src}) - artifact = _artifact(build, "x") + compile_files([source], build, {("", "app"): src}) + artifact = _artifact(build, "app/x") mtime_first = artifact.stat().st_mtime - compile_files([source], build, {"": src}) + compile_files([source], build, {("", "app"): src}) mtime_second = artifact.stat().st_mtime assert mtime_first == mtime_second # artifact was NOT rewritten @@ -129,14 +129,14 @@ def test_stale_artifact_triggers_recompile(self, tmp_path: Path) -> None: source = src / "x.archml" _write(source, "component A {}") # mtime 2s in the past - compile_files([source], build, {"": src}) - artifact = _artifact(build, "x") + compile_files([source], build, {("", "app"): src}) + artifact = _artifact(build, "app/x") content_first = artifact.read_text(encoding="utf-8") # Touch the source file to make it newer than the artifact. _write(source, "component B {}", mtime_offset=2.0) # mtime = 2s in the future - compile_files([source], build, {"": src}) + compile_files([source], build, {("", "app"): src}) content_second = artifact.read_text(encoding="utf-8") assert content_second != content_first # artifact was rewritten with new content @@ -147,12 +147,12 @@ def test_stale_artifact_reads_updated_content(self, tmp_path: Path) -> None: source = src / "x.archml" _write(source, "component A {}", mtime_offset=-2.0) - compile_files([source], build, {"": src}) + compile_files([source], build, {("", "app"): src}) _write(source, "component NewComp {}", mtime_offset=2.0) # 2s in future = newer than artifact - result = compile_files([source], build, {"": src}) + result = compile_files([source], build, {("", "app"): src}) - assert result["x"].components[0].name == "NewComp" + assert result["app/x"].components[0].name == "NewComp" # ############### @@ -171,45 +171,47 @@ def test_compiles_file_with_dependency(self, tmp_path: Path) -> None: _write( src / "app.archml", """ -from types import Signal +from app/types import Signal component Worker { requires Signal } """, ) - result = compile_files([src / "app.archml"], build, {"": src}) - assert "app" in result - assert "types" in result + result = compile_files([src / "app.archml"], build, {("", "app"): src}) + assert "app/app" in result + assert "app/types" in result def test_dependency_artifact_also_written(self, tmp_path: Path) -> None: src = tmp_path / "src" build = tmp_path / "build" _write(src / "types.archml", "interface Signal { field v: Int }") - _write(src / "app.archml", "from types import Signal\ncomponent W { requires Signal }") - compile_files([src / "app.archml"], build, {"": src}) - types_artifact = _artifact(build, "types") + _write(src / "app.archml", "from app/types import Signal\ncomponent W { requires Signal }") + compile_files([src / "app.archml"], build, {("", "app"): src}) + types_artifact = _artifact(build, "app/types") assert types_artifact.exists() def test_three_level_dependency_chain(self, tmp_path: Path) -> None: src = tmp_path / "src" build = tmp_path / "build" _write(src / "base.archml", "interface IBase { field x: Int }") - _write(src / "mid.archml", "from base import IBase\ncomponent Mid { requires IBase }") - _write(src / "top.archml", "from base import IBase\nfrom mid import Mid\ncomponent Top { requires IBase }") - result = compile_files([src / "top.archml"], build, {"": src}) - assert "base" in result - assert "mid" in result - assert "top" in result + _write(src / "mid.archml", "from app/base import IBase\ncomponent Mid { requires IBase }") + _write( + src / "top.archml", "from app/base import IBase\nfrom app/mid import Mid\ncomponent Top { requires IBase }" + ) + result = compile_files([src / "top.archml"], build, {("", "app"): src}) + assert "app/base" in result + assert "app/mid" in result + assert "app/top" in result def test_shared_dependency_compiled_once(self, tmp_path: Path) -> None: """Two top-level files sharing a dependency don't recompile it.""" src = tmp_path / "src" build = tmp_path / "build" _write(src / "shared.archml", "interface I { field v: Int }") - _write(src / "a.archml", "from shared import I\ncomponent A { requires I }") - _write(src / "b.archml", "from shared import I\ncomponent B { requires I }") - result = compile_files([src / "a.archml", src / "b.archml"], build, {"": src}) - assert "shared" in result - assert "a" in result - assert "b" in result + _write(src / "a.archml", "from app/shared import I\ncomponent A { requires I }") + _write(src / "b.archml", "from app/shared import I\ncomponent B { requires I }") + result = compile_files([src / "a.archml", src / "b.archml"], build, {("", "app"): src}) + assert "app/shared" in result + assert "app/a" in result + assert "app/b" in result def test_subdirectory_dependency(self, tmp_path: Path) -> None: src = tmp_path / "src" @@ -217,18 +219,18 @@ def test_subdirectory_dependency(self, tmp_path: Path) -> None: _write(src / "shared" / "types.archml", "interface Signal { field v: Int }") _write( src / "worker.archml", - "from shared/types import Signal\ncomponent Worker { requires Signal }", + "from app/shared/types import Signal\ncomponent Worker { requires Signal }", ) - result = compile_files([src / "worker.archml"], build, {"": src}) - assert "worker" in result - assert "shared/types" in result + result = compile_files([src / "worker.archml"], build, {("", "app"): src}) + assert "app/worker" in result + assert "app/shared/types" in result def test_compiled_from_test_data(self, tmp_path: Path) -> None: """Compile the realistic multi-file test data under tests/data/positive/compiler/.""" result = compile_files( [DATA_DIR / "system.archml"], tmp_path / "build", - {"": DATA_DIR.parent}, + {("", "compiler"): DATA_DIR}, ) assert "compiler/system" in result assert "compiler/worker" in result @@ -256,9 +258,9 @@ def test_resolves_mnemonic_import(self, tmp_path: Path) -> None: result = compile_files( [src / "app.archml"], build, - {"": src, "mylib": lib}, + {("", "app"): src, ("", "mylib"): lib}, ) - assert "app" in result + assert "app/app" in result assert "mylib/types" in result def test_mnemonic_artifact_stored_under_mnemonic_prefix(self, tmp_path: Path) -> None: @@ -270,7 +272,7 @@ def test_mnemonic_artifact_stored_under_mnemonic_prefix(self, tmp_path: Path) -> _write(lib / "types.archml", "interface Signal { field v: Int }") _write(src / "app.archml", "from mylib/types import Signal\ncomponent C { requires Signal }") - compile_files([src / "app.archml"], build, {"": src, "mylib": lib}) + compile_files([src / "app.archml"], build, {("", "app"): src, ("", "mylib"): lib}) assert _artifact(build, "mylib/types").exists() @@ -289,7 +291,7 @@ def test_mnemonic_import_in_subdirectory(self, tmp_path: Path) -> None: result = compile_files( [src / "app.archml"], build, - {"": src, "mylib": lib}, + {("", "app"): src, ("", "mylib"): lib}, ) assert "mylib/shared/base" in result @@ -311,7 +313,7 @@ def test_multiple_mnemonics(self, tmp_path: Path) -> None: result = compile_files( [src / "app.archml"], build, - {"": src, "liba": lib_a, "libb": lib_b}, + {("", "app"): src, ("", "liba"): lib_a, ("", "libb"): lib_b}, ) assert "liba/types" in result assert "libb/types" in result @@ -324,34 +326,34 @@ def test_mnemonic_dependency_compiled_transitively(self, tmp_path: Path) -> None _write(lib / "iface.archml", "interface IFace { field v: Int }") _write(src / "mid.archml", "from ext/iface import IFace\ncomponent Mid { requires IFace }") - _write(src / "top.archml", "from mid import Mid\ncomponent Top {}") + _write(src / "top.archml", "from app/mid import Mid\ncomponent Top {}") result = compile_files( [src / "top.archml"], build, - {"": src, "ext": lib}, + {("", "app"): src, ("", "ext"): lib}, ) - assert "top" in result - assert "mid" in result + assert "app/top" in result + assert "app/mid" in result assert "ext/iface" in result - def test_remote_git_import_raises_if_repo_not_in_source_import_map(self, tmp_path: Path) -> None: - """Importing via @repo/path raises CompilerError when @repo is not in source_import_map.""" + def test_remote_git_import_raises_if_mnemonic_not_in_source_import_map(self, tmp_path: Path) -> None: + """Importing via @repo/mnemonic/path raises CompilerError when the mnemonic is absent.""" src = tmp_path / "src" build = tmp_path / "build" _write(src / "app.archml", "from @myrepo/mylib/types import X\ncomponent C {}") with pytest.raises(CompilerError, match="not found in workspace"): - compile_files([src / "app.archml"], build, {"": src}) + compile_files([src / "app.archml"], build, {("", "app"): src}) def test_remote_git_import_resolves_from_source_import_map(self, tmp_path: Path) -> None: - """Files imported via @repo/path are resolved when @repo is in source_import_map.""" + """Files imported via @repo/mnemonic/path resolve when the mnemonic key is present.""" src = tmp_path / "src" - remote = tmp_path / "remote" + remote_services = tmp_path / "remote" / "services" build = tmp_path / "build" - _write(remote / "services" / "payment.archml", "interface PaymentService { field amount: Decimal }") + _write(remote_services / "payment.archml", "interface PaymentService { field amount: Decimal }") _write( src / "app.archml", "from @payments/services/payment import PaymentService\ncomponent C { requires PaymentService }", @@ -360,29 +362,29 @@ def test_remote_git_import_resolves_from_source_import_map(self, tmp_path: Path) result = compile_files( [src / "app.archml"], build, - {"": src, "@payments": remote}, + {("", "app"): src, ("@payments", "services"): remote_services}, ) - assert "app" in result + assert "app/app" in result assert "@payments/services/payment" in result def test_remote_git_import_artifact_stored_under_at_prefix(self, tmp_path: Path) -> None: - """Artifacts for remote git imports are stored under @repo/ in the build dir.""" + """Artifacts for remote git imports are stored under @repo/mnemonic/ in the build dir.""" src = tmp_path / "src" - remote = tmp_path / "remote" + remote_api = tmp_path / "remote" / "api" build = tmp_path / "build" - _write(remote / "types.archml", "interface RemoteType { field v: Int }") - _write(src / "app.archml", "from @ext/types import RemoteType\ncomponent C { requires RemoteType }") + _write(remote_api / "types.archml", "interface RemoteType { field v: Int }") + _write(src / "app.archml", "from @ext/api/types import RemoteType\ncomponent C { requires RemoteType }") - compile_files([src / "app.archml"], build, {"": src, "@ext": remote}) + compile_files([src / "app.archml"], build, {("", "app"): src, ("@ext", "api"): remote_api}) - assert _artifact(build, "@ext/types").exists() + assert _artifact(build, "@ext/api/types").exists() - def test_remote_git_import_with_mnemonic_resolves_from_two_segment_key(self, tmp_path: Path) -> None: - """@repo/mnemonic/path imports resolve via @repo/mnemonic key in source_import_map.""" + def test_remote_git_import_with_mnemonic_resolves_via_mnemonic_key(self, tmp_path: Path) -> None: + """@repo/mnemonic/path imports resolve via (repo, mnemonic) key in source_import_map.""" src = tmp_path / "src" - remote_utils = tmp_path / "remote" / "src" / "utils" + remote_utils = tmp_path / "remote" / "utils" build = tmp_path / "build" _write(remote_utils / "helpers.archml", "interface Helper { field v: Int }") @@ -394,53 +396,24 @@ def test_remote_git_import_with_mnemonic_resolves_from_two_segment_key(self, tmp result = compile_files( [src / "app.archml"], build, - {"": src, "@payments": tmp_path / "remote", "@payments/utils": remote_utils}, + {("", "app"): src, ("@payments", "utils"): remote_utils}, ) - assert "app" in result + assert "app/app" in result assert "@payments/utils/helpers" in result - def test_remote_git_import_mnemonic_key_takes_precedence_over_root(self, tmp_path: Path) -> None: - """When @repo/mnemonic key exists it is used instead of @repo root for that prefix.""" - src = tmp_path / "src" - remote_root = tmp_path / "remote" - remote_lib = tmp_path / "remote-lib" - build = tmp_path / "build" - - # File exists in lib dir (via mnemonic), NOT under remote_root/lib/ - _write(remote_lib / "types.archml", "interface LibType { field v: Int }") - _write( - src / "app.archml", - "from @ext/lib/types import LibType\ncomponent C { requires LibType }", - ) - - result = compile_files( - [src / "app.archml"], - build, - {"": src, "@ext": remote_root, "@ext/lib": remote_lib}, - ) - - assert "@ext/lib/types" in result - - def test_remote_git_import_falls_back_to_root_when_no_mnemonic_key(self, tmp_path: Path) -> None: - """When no @repo/mnemonic key matches, resolution falls back to @repo root.""" + def test_remote_git_import_raises_when_mnemonic_not_configured(self, tmp_path: Path) -> None: + """@repo/mnemonic/path raises CompilerError when the mnemonic is not in the map.""" src = tmp_path / "src" - remote = tmp_path / "remote" build = tmp_path / "build" - _write(remote / "lib" / "types.archml", "interface T { field v: Int }") _write( src / "app.archml", "from @ext/lib/types import T\ncomponent C { requires T }", ) - result = compile_files( - [src / "app.archml"], - build, - {"": src, "@ext": remote}, # no "@ext/lib" mnemonic key - ) - - assert "@ext/lib/types" in result + with pytest.raises(CompilerError, match="not found in workspace"): + compile_files([src / "app.archml"], build, {("", "app"): src}) def test_mnemonic_missing_file_raises_compiler_error(self, tmp_path: Path) -> None: """A mnemonic import that refers to a non-existent file raises CompilerError.""" @@ -452,16 +425,16 @@ def test_mnemonic_missing_file_raises_compiler_error(self, tmp_path: Path) -> No _write(src / "app.archml", "from mylib/missing import X\ncomponent C {}") with pytest.raises(CompilerError, match="not found"): - compile_files([src / "app.archml"], build, {"": src, "mylib": lib}) + compile_files([src / "app.archml"], build, {("", "app"): src, ("", "mylib"): lib}) - def test_workspace_root_resolves_non_mnemonic_imports(self, tmp_path: Path) -> None: - """compile_files resolves non-mnemonic imports from the workspace root (\"\").""" + def test_mnemonic_based_import_resolves_correctly(self, tmp_path: Path) -> None: + """compile_files resolves mnemonic-based imports from the source import map.""" src = tmp_path / "src" build = tmp_path / "build" _write(src / "x.archml", "component C {}") - result = compile_files([src / "x.archml"], build, {"": src}) - assert "x" in result + result = compile_files([src / "x.archml"], build, {("", "app"): src}) + assert "app/x" in result def test_mnemonic_cache_hit(self, tmp_path: Path) -> None: """A cached artifact for a mnemonic import is reused on subsequent builds.""" @@ -472,15 +445,52 @@ def test_mnemonic_cache_hit(self, tmp_path: Path) -> None: _write(lib / "types.archml", "interface Signal { field v: Int }") _write(src / "app.archml", "from mylib/types import Signal\ncomponent W { requires Signal }") - compile_files([src / "app.archml"], build, {"": src, "mylib": lib}) + compile_files([src / "app.archml"], build, {("", "app"): src, ("", "mylib"): lib}) artifact = _artifact(build, "mylib/types") mtime_first = artifact.stat().st_mtime - compile_files([src / "app.archml"], build, {"": src, "mylib": lib}) + compile_files([src / "app.archml"], build, {("", "app"): src, ("", "mylib"): lib}) mtime_second = artifact.stat().st_mtime assert mtime_first == mtime_second + def test_bare_import_without_mnemonic_raises_compiler_error(self, tmp_path: Path) -> None: + """An import without a mnemonic prefix raises CompilerError.""" + src = tmp_path / "src" + build = tmp_path / "build" + + _write(src / "app.archml", "from types import Signal\ncomponent C {}") + + with pytest.raises(CompilerError, match="mnemonic/path"): + compile_files([src / "app.archml"], build, {("", "app"): src}) + + def test_remote_import_without_path_raises_compiler_error(self, tmp_path: Path) -> None: + """A remote import missing the path component raises CompilerError.""" + src = tmp_path / "src" + build = tmp_path / "build" + + _write(src / "app.archml", "from @repo/mnemonic import X\ncomponent C {}") + + with pytest.raises(CompilerError, match="missing path component"): + compile_files([src / "app.archml"], build, {("", "app"): src}) + + def test_local_mnemonic_resolves_using_source_repo(self, tmp_path: Path) -> None: + """Bare mnemonic imports resolve using the source file's repo context.""" + lib = tmp_path / "lib" + build = tmp_path / "build" + + _write(lib / "utils.archml", "interface IUtil { field v: Int }") + # lib/main.archml imports from "lib" mnemonic (same repo "") + _write(lib / "main.archml", "from lib/utils import IUtil\ncomponent Main { requires IUtil }") + + result = compile_files( + [lib / "main.archml"], + build, + {("", "lib"): lib}, + ) + assert "lib/main" in result + assert "lib/utils" in result + # ############### # File-move recompilation @@ -493,28 +503,27 @@ def test_cache_busted_when_local_dep_is_moved(self, tmp_path: Path) -> None: src = tmp_path / "src" build = tmp_path / "build" - # Initial setup: app imports dep from 'types' + # Initial setup: app imports types from same mnemonic _write(src / "types.archml", "interface Signal { field v: Int }") - _write(src / "app.archml", "from types import Signal\ncomponent W { requires Signal }") + _write(src / "app.archml", "from app/types import Signal\ncomponent W { requires Signal }") - compile_files([src / "app.archml"], build, {"": src}) - app_artifact = _artifact(build, "app") + compile_files([src / "app.archml"], build, {("", "app"): src}) + app_artifact = _artifact(build, "app/app") mtime_before = app_artifact.stat().st_mtime # Simulate file being moved: delete 'types.archml' (old location no longer exists) (src / "types.archml").unlink() - # Place the file at a new location (new FQN) and create the renamed dep + # Place the file at a new location and update app to import from new location _write(src / "signals" / "types.archml", "interface Signal { field v: Int }") - # Also update app to import from new location (moved file scenario) - _write(src / "app.archml", "from signals/types import Signal\ncomponent W { requires Signal }") + _write(src / "app.archml", "from app/signals/types import Signal\ncomponent W { requires Signal }") - compile_files([src / "app.archml"], build, {"": src}) + compile_files([src / "app.archml"], build, {("", "app"): src}) mtime_after = app_artifact.stat().st_mtime # app was recompiled because its source changed assert mtime_after != mtime_before - assert "signals/types" in compile_files([src / "app.archml"], build, {"": src}) + assert "app/signals/types" in compile_files([src / "app.archml"], build, {("", "app"): src}) def test_cache_busted_when_mnemonic_dep_is_moved(self, tmp_path: Path) -> None: """Cache is invalidated when a mnemonic-based dependency no longer exists.""" @@ -526,8 +535,8 @@ def test_cache_busted_when_mnemonic_dep_is_moved(self, tmp_path: Path) -> None: _write(lib / "types.archml", "interface Signal { field v: Int }") _write(src / "app.archml", "from mylib/types import Signal\ncomponent W { requires Signal }") - compile_files([src / "app.archml"], build, {"": src, "mylib": lib}) - app_artifact = _artifact(build, "app") + compile_files([src / "app.archml"], build, {("", "app"): src, ("", "mylib"): lib}) + app_artifact = _artifact(build, "app/app") assert app_artifact.exists() # Simulate a move: the dependency at mylib/types is gone @@ -537,7 +546,7 @@ def test_cache_busted_when_mnemonic_dep_is_moved(self, tmp_path: Path) -> None: # Now trigger a recompile: since mylib/types is gone, the cache for # 'app' should be busted and re-parsing app should fail because the dep is missing. with pytest.raises(CompilerError, match="not found"): - compile_files([src / "app.archml"], build, {"": src, "mylib": lib}) + compile_files([src / "app.archml"], build, {("", "app"): src, ("", "mylib"): lib}) def test_up_to_date_cache_hit_survives_when_deps_still_exist(self, tmp_path: Path) -> None: """An up-to-date artifact is reused when all its imports still exist.""" @@ -548,12 +557,12 @@ def test_up_to_date_cache_hit_survives_when_deps_still_exist(self, tmp_path: Pat _write(lib / "types.archml", "interface Signal { field v: Int }") _write(src / "app.archml", "from mylib/types import Signal\ncomponent W { requires Signal }") - compile_files([src / "app.archml"], build, {"": src, "mylib": lib}) - app_artifact = _artifact(build, "app") + compile_files([src / "app.archml"], build, {("", "app"): src, ("", "mylib"): lib}) + app_artifact = _artifact(build, "app/app") mtime_before = app_artifact.stat().st_mtime # Second run: deps still exist, artifact is up-to-date → cache hit - compile_files([src / "app.archml"], build, {"": src, "mylib": lib}) + compile_files([src / "app.archml"], build, {("", "app"): src, ("", "mylib"): lib}) mtime_after = app_artifact.stat().st_mtime assert mtime_before == mtime_after @@ -570,14 +579,14 @@ def test_parse_error_raises_compiler_error(self, tmp_path: Path) -> None: build = tmp_path / "build" _write(src / "bad.archml", "component {}") # missing name with pytest.raises(CompilerError, match="Parse error"): - compile_files([src / "bad.archml"], build, {"": src}) + compile_files([src / "bad.archml"], build, {("", "app"): src}) def test_missing_dependency_raises_compiler_error(self, tmp_path: Path) -> None: src = tmp_path / "src" build = tmp_path / "build" - _write(src / "app.archml", "from nonexistent import Something\ncomponent C {}") + _write(src / "app.archml", "from app/nonexistent import Something\ncomponent C {}") with pytest.raises(CompilerError, match="not found"): - compile_files([src / "app.archml"], build, {"": src}) + compile_files([src / "app.archml"], build, {("", "app"): src}) def test_semantic_error_raises_compiler_error(self, tmp_path: Path) -> None: src = tmp_path / "src" @@ -587,23 +596,23 @@ def test_semantic_error_raises_compiler_error(self, tmp_path: Path) -> None: "component C { requires UnknownInterface }", ) with pytest.raises(CompilerError, match="Semantic errors"): - compile_files([src / "bad.archml"], build, {"": src}) + compile_files([src / "bad.archml"], build, {("", "app"): src}) def test_circular_dependency_raises_compiler_error(self, tmp_path: Path) -> None: src = tmp_path / "src" build = tmp_path / "build" # a imports b, b imports a - _write(src / "a.archml", "from b import Something\ncomponent A {}") - _write(src / "b.archml", "from a import Something\ncomponent B {}") + _write(src / "a.archml", "from app/b import Something\ncomponent A {}") + _write(src / "b.archml", "from app/a import Something\ncomponent B {}") with pytest.raises(CompilerError, match="Circular dependency"): - compile_files([src / "a.archml"], build, {"": src}) + compile_files([src / "a.archml"], build, {("", "app"): src}) def test_compiler_error_message_includes_file_path(self, tmp_path: Path) -> None: src = tmp_path / "src" build = tmp_path / "build" _write(src / "myfile.archml", "component {}") with pytest.raises(CompilerError) as exc_info: - compile_files([src / "myfile.archml"], build, {"": src}) + compile_files([src / "myfile.archml"], build, {("", "app"): src}) assert "myfile" in str(exc_info.value) def test_multiple_semantic_errors_in_message(self, tmp_path: Path) -> None: @@ -617,7 +626,7 @@ def test_multiple_semantic_errors_in_message(self, tmp_path: Path) -> None: """, ) with pytest.raises(CompilerError, match="Semantic errors"): - compile_files([src / "bad.archml"], build, {"": src}) + compile_files([src / "bad.archml"], build, {("", "app"): src}) # ############### @@ -627,26 +636,26 @@ def test_multiple_semantic_errors_in_message(self, tmp_path: Path) -> None: class TestReturnValue: def test_returns_empty_dict_for_no_files(self, tmp_path: Path) -> None: - result = compile_files([], tmp_path / "build", {"": tmp_path / "src"}) + result = compile_files([], tmp_path / "build", {("", "app"): tmp_path / "src"}) assert result == {} - def test_key_uses_relative_path_without_extension(self, tmp_path: Path) -> None: + def test_key_uses_mnemonic_and_relative_path_without_extension(self, tmp_path: Path) -> None: src = tmp_path / "src" build = tmp_path / "build" _write(src / "subdir" / "myfile.archml", "component C {}") - result = compile_files([src / "subdir" / "myfile.archml"], build, {"": src}) - assert "subdir/myfile" in result + result = compile_files([src / "subdir" / "myfile.archml"], build, {("", "app"): src}) + assert "app/subdir/myfile" in result def test_compiling_same_file_twice_returns_same_model(self, tmp_path: Path) -> None: src = tmp_path / "src" build = tmp_path / "build" _write(src / "x.archml", "component C {}") - result = compile_files([src / "x.archml", src / "x.archml"], build, {"": src}) + result = compile_files([src / "x.archml", src / "x.archml"], build, {("", "app"): src}) assert len(result) == 1 - assert "x" in result + assert "app/x" in result def test_mnemonic_key_uses_mnemonic_prefix(self, tmp_path: Path) -> None: - """Keys for mnemonic imports use mnemonic/path format (no @ prefix).""" + """Keys for mnemonic imports use mnemonic/path format.""" src = tmp_path / "src" lib = tmp_path / "lib" build = tmp_path / "build" @@ -654,5 +663,5 @@ def test_mnemonic_key_uses_mnemonic_prefix(self, tmp_path: Path) -> None: _write(lib / "iface.archml", "interface I { field v: Int }") _write(src / "app.archml", "from ext/iface import I\ncomponent C { requires I }") - result = compile_files([src / "app.archml"], build, {"": src, "ext": lib}) + result = compile_files([src / "app.archml"], build, {("", "app"): src, ("", "ext"): lib}) assert "ext/iface" in result diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index d73b81a..e36e36c 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -8,9 +8,7 @@ from archml.workspace.config import ( GitPathImport, LocalPathImport, - WorkspaceConfig, WorkspaceConfigError, - find_workspace_root, load_workspace_config, ) @@ -19,28 +17,17 @@ # ############### -def test_load_minimal_config(tmp_path): - """A config with only build-directory and no source-imports is valid.""" - cfg_file = tmp_path / ".archml-workspace.yaml" - cfg_file.write_text("build-directory: build\n", encoding="utf-8") - - config = load_workspace_config(cfg_file) - - assert isinstance(config, WorkspaceConfig) - assert config.build_directory == "build" - assert config.source_imports == [] - - def test_load_config_with_local_path_import(tmp_path): """A source import with local-path is parsed as LocalPathImport.""" cfg_file = tmp_path / ".archml-workspace.yaml" cfg_file.write_text( - "build-directory: out\nsource-imports:\n - name: common\n local-path: src/common\n", + "name: myworkspace\nbuild-directory: out\nsource-imports:\n - name: common\n local-path: src/common\n", encoding="utf-8", ) config = load_workspace_config(cfg_file) + assert config.name == "myworkspace" assert config.build_directory == "out" assert len(config.source_imports) == 1 imp = config.source_imports[0] @@ -53,7 +40,7 @@ def test_load_config_with_git_import(tmp_path): """A source import with git-repository and revision is parsed as GitPathImport.""" cfg_file = tmp_path / ".archml-workspace.yaml" cfg_file.write_text( - "build-directory: out\n" + "name: myworkspace\nbuild-directory: out\n" "source-imports:\n" " - name: external\n" " git-repository: https://github.com/example/repo\n" @@ -75,7 +62,7 @@ def test_load_config_with_mixed_imports(tmp_path): """A config may contain both local and git source imports.""" cfg_file = tmp_path / ".archml-workspace.yaml" cfg_file.write_text( - "build-directory: out\n" + "name: myworkspace\nbuild-directory: out\n" "source-imports:\n" " - name: local-lib\n" " local-path: libs/local\n" @@ -92,19 +79,6 @@ def test_load_config_with_mixed_imports(tmp_path): assert isinstance(config.source_imports[1], GitPathImport) -def test_load_config_with_empty_source_imports(tmp_path): - """An explicit empty source-imports list is accepted.""" - cfg_file = tmp_path / ".archml-workspace.yaml" - cfg_file.write_text( - "build-directory: build\nsource-imports: []\n", - encoding="utf-8", - ) - - config = load_workspace_config(cfg_file) - - assert config.source_imports == [] - - def test_error_file_not_found(tmp_path): """Loading a nonexistent file raises WorkspaceConfigError.""" missing = tmp_path / "no-such-file.yaml" @@ -125,7 +99,10 @@ def test_error_invalid_yaml_syntax(tmp_path): def test_error_missing_build_directory(tmp_path): """Omitting build-directory raises WorkspaceConfigError.""" cfg_file = tmp_path / ".archml-workspace.yaml" - cfg_file.write_text("source-imports: []\n", encoding="utf-8") + cfg_file.write_text( + "name: myworkspace\nsource-imports:\n - name: src\n local-path: .\n", + encoding="utf-8", + ) with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): load_workspace_config(cfg_file) @@ -144,7 +121,7 @@ def test_error_source_imports_not_a_list(tmp_path): """source-imports must be a list; a scalar value raises WorkspaceConfigError.""" cfg_file = tmp_path / ".archml-workspace.yaml" cfg_file.write_text( - "build-directory: build\nsource-imports: not-a-list\n", + "name: myworkspace\nbuild-directory: build\nsource-imports: not-a-list\n", encoding="utf-8", ) @@ -156,7 +133,7 @@ def test_error_import_both_local_and_git(tmp_path): """Specifying both local-path and git-repository raises WorkspaceConfigError.""" cfg_file = tmp_path / ".archml-workspace.yaml" cfg_file.write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" " - name: conflict\n" " local-path: some/path\n" @@ -173,7 +150,7 @@ def test_error_import_neither_local_nor_git(tmp_path): """An import entry with only a name (no local-path or git-repository) raises WorkspaceConfigError.""" cfg_file = tmp_path / ".archml-workspace.yaml" cfg_file.write_text( - "build-directory: build\nsource-imports:\n - name: incomplete\n", + "name: myworkspace\nbuild-directory: build\nsource-imports:\n - name: incomplete\n", encoding="utf-8", ) @@ -185,7 +162,7 @@ def test_error_git_import_missing_revision(tmp_path): """A git import without revision raises WorkspaceConfigError.""" cfg_file = tmp_path / ".archml-workspace.yaml" cfg_file.write_text( - "build-directory: build\n" + "name: myworkspace\nbuild-directory: build\n" "source-imports:\n" " - name: external\n" " git-repository: https://github.com/example/repo\n", @@ -200,7 +177,9 @@ def test_error_unknown_top_level_field(tmp_path): """An unrecognised top-level key raises WorkspaceConfigError.""" cfg_file = tmp_path / ".archml-workspace.yaml" cfg_file.write_text( - "build-directory: build\nunknown-field: oops\n", + "name: myworkspace\nbuild-directory: build\n" + "source-imports:\n - name: src\n local-path: .\n" + "unknown-field: oops\n", encoding="utf-8", ) @@ -212,7 +191,9 @@ def test_load_config_with_remote_sync_directory(tmp_path): """A config with remote-sync-directory is parsed correctly.""" cfg_file = tmp_path / ".archml-workspace.yaml" cfg_file.write_text( - "build-directory: build\nremote-sync-directory: .remotes\n", + "name: myworkspace\nbuild-directory: build\n" + "remote-sync-directory: .remotes\n" + "source-imports:\n - name: src\n local-path: .\n", encoding="utf-8", ) @@ -224,61 +205,233 @@ def test_load_config_with_remote_sync_directory(tmp_path): def test_load_config_default_remote_sync_directory(tmp_path): """When remote-sync-directory is absent, the default is '.archml-remotes'.""" cfg_file = tmp_path / ".archml-workspace.yaml" - cfg_file.write_text("build-directory: build\n", encoding="utf-8") + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\nsource-imports:\n - name: src\n local-path: .\n", + encoding="utf-8", + ) config = load_workspace_config(cfg_file) assert config.remote_sync_directory == ".archml-remotes" -def test_find_workspace_root_in_current_directory(tmp_path): - """find_workspace_root returns the directory itself when it contains the workspace file.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n", encoding="utf-8") +# ############### +# Workspace name validation +# ############### + + +def test_error_missing_workspace_name(tmp_path): + """Omitting the workspace name raises WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "build-directory: build\nsource-imports:\n - name: src\n local-path: .\n", + encoding="utf-8", + ) + + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) + + +def test_error_workspace_name_with_uppercase(tmp_path): + """A workspace name with uppercase letters raises WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: MyWorkspace\nbuild-directory: build\nsource-imports:\n - name: src\n local-path: .\n", + encoding="utf-8", + ) + + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) + - result = find_workspace_root(tmp_path) +def test_error_workspace_name_starts_with_digit(tmp_path): + """A workspace name starting with a digit raises WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: 1workspace\nbuild-directory: build\nsource-imports:\n - name: src\n local-path: .\n", + encoding="utf-8", + ) - assert result == tmp_path + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) -def test_find_workspace_root_in_parent_directory(tmp_path): - """find_workspace_root walks up and finds the workspace in a parent directory.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n", encoding="utf-8") - child_dir = tmp_path / "subdir" / "nested" - child_dir.mkdir(parents=True) +# ############### +# Mnemonic name validation +# ############### - result = find_workspace_root(child_dir) - assert result == tmp_path +def test_error_mnemonic_missing_source_imports(tmp_path): + """Omitting source-imports entirely raises WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text("name: myworkspace\nbuild-directory: build\n", encoding="utf-8") + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) -def test_find_workspace_root_returns_none_when_not_found(tmp_path): - """find_workspace_root returns None when no workspace file exists anywhere in the tree.""" - child_dir = tmp_path / "subdir" - child_dir.mkdir() - result = find_workspace_root(child_dir) +def test_error_empty_source_imports_list(tmp_path): + """An empty source-imports list raises WorkspaceConfigError (at least one required).""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\nsource-imports: []\n", + encoding="utf-8", + ) - assert result is None + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) -def test_find_workspace_root_uses_nearest_ancestor(tmp_path): - """find_workspace_root returns the closest (innermost) workspace directory.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: outer\n", encoding="utf-8") - inner_dir = tmp_path / "inner" - inner_dir.mkdir() - (inner_dir / ".archml-workspace.yaml").write_text("build-directory: inner\n", encoding="utf-8") - nested = inner_dir / "deep" - nested.mkdir() +def test_error_mnemonic_name_with_uppercase(tmp_path): + """A mnemonic name with uppercase letters raises WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\nsource-imports:\n - name: MyLib\n local-path: .\n", + encoding="utf-8", + ) - result = find_workspace_root(nested) + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) - assert result == inner_dir +def test_error_mnemonic_name_starts_with_digit(tmp_path): + """A mnemonic name starting with a digit raises WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\nsource-imports:\n - name: 1lib\n local-path: .\n", + encoding="utf-8", + ) -def test_find_workspace_root_returns_given_dir_when_it_is_the_root(tmp_path): - """find_workspace_root resolves the start directory and finds the workspace in it.""" - (tmp_path / ".archml-workspace.yaml").write_text("build-directory: build\n", encoding="utf-8") + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) - result = find_workspace_root(tmp_path / ".") - assert result == tmp_path +def test_error_mnemonic_name_with_slash(tmp_path): + """A mnemonic name containing a slash raises WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\nsource-imports:\n - name: my/lib\n local-path: .\n", + encoding="utf-8", + ) + + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) + + +def test_error_mnemonic_name_with_space(tmp_path): + """A mnemonic name containing a space raises WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\nsource-imports:\n - name: my lib\n local-path: .\n", + encoding="utf-8", + ) + + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) + + +def test_error_mnemonic_name_starts_with_dash(tmp_path): + """A mnemonic name starting with a dash raises WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\nsource-imports:\n - name: -mylib\n local-path: .\n", + encoding="utf-8", + ) + + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) + + +def test_valid_mnemonic_names(tmp_path): + """Valid mnemonic names using letters, digits, dashes, and underscores are accepted.""" + valid_names = ["src", "mylib", "my-lib", "my-lib-2", "a", "lib123", "my_lib"] + for name in valid_names: + cfg_file = tmp_path / f".archml-workspace-{name}.yaml" + cfg_file.write_text( + f"name: myworkspace\nbuild-directory: build\nsource-imports:\n - name: {name}\n local-path: .\n", + encoding="utf-8", + ) + config = load_workspace_config(cfg_file) + assert config.source_imports[0].name == name + + +def test_error_git_repo_name_with_slash(tmp_path): + """A git repo name containing a slash raises WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\n" + "source-imports:\n" + " - name: my/repo\n" + " git-repository: https://github.com/example/repo\n" + " revision: main\n", + encoding="utf-8", + ) + + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) + + +# ############### +# Unique import name validation +# ############### + + +def test_error_duplicate_local_import_names(tmp_path): + """Two local imports with the same name raise WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\n" + "source-imports:\n" + " - name: mylib\n local-path: a\n" + " - name: mylib\n local-path: b\n", + encoding="utf-8", + ) + + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) + + +def test_error_duplicate_git_import_names(tmp_path): + """Two git imports with the same name raise WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\n" + "source-imports:\n" + " - name: payments\n git-repository: https://example.com/a\n revision: main\n" + " - name: payments\n git-repository: https://example.com/b\n revision: main\n", + encoding="utf-8", + ) + + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) + + +def test_error_duplicate_name_across_local_and_git(tmp_path): + """A local import and a git import sharing a name raise WorkspaceConfigError.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\n" + "source-imports:\n" + " - name: shared\n local-path: .\n" + " - name: shared\n git-repository: https://example.com/shared\n revision: main\n", + encoding="utf-8", + ) + + with pytest.raises(WorkspaceConfigError, match="Invalid workspace config"): + load_workspace_config(cfg_file) + + +def test_unique_import_names_are_accepted(tmp_path): + """Multiple imports with distinct names are accepted.""" + cfg_file = tmp_path / ".archml-workspace.yaml" + cfg_file.write_text( + "name: myworkspace\nbuild-directory: build\n" + "source-imports:\n" + " - name: local-src\n local-path: .\n" + " - name: remote-lib\n git-repository: https://example.com/lib\n revision: main\n", + encoding="utf-8", + ) + + config = load_workspace_config(cfg_file) + + assert len(config.source_imports) == 2