From 51082012f9fd8fbc1b9ef8d9cdbe5b3b04ba4939 Mon Sep 17 00:00:00 2001 From: sergio-sisternes-epam Date: Tue, 24 Feb 2026 14:31:17 +0000 Subject: [PATCH 1/4] fix: virtual subdirectory deps marked as orphaned, skipping instruction processing Virtual subdirectory packages (owner/repo/subdir) were incorrectly flattened to owner/virtual-pkg-name in both dependency discovery and orphan detection, causing: - apm deps list: dependency shown as 'orphaned' - apm deps tree: dependency shown as 'unknown' - apm compile: Instruction Processing 0.0ms (no primitives found) Fixed get_dependency_declaration_order() and orphan detection to use natural path (owner/repo/subdir) for is_virtual_subdirectory() deps, matching DependencyReference.get_install_path() semantics. Refs: #99 --- src/apm_cli/commands/deps.py | 29 +++-- src/apm_cli/primitives/discovery.py | 40 ++++--- .../test_virtual_package_orphan_detection.py | 104 ++++++++++++++++-- 3 files changed, 145 insertions(+), 28 deletions(-) diff --git a/src/apm_cli/commands/deps.py b/src/apm_cli/commands/deps.py index cc795b0b..19b54636 100644 --- a/src/apm_cli/commands/deps.py +++ b/src/apm_cli/commands/deps.py @@ -61,14 +61,27 @@ def list_packages(): # Build the expected installed package name repo_parts = dep.repo_url.split('/') if dep.is_virtual: - # Virtual package: include full path based on platform - package_name = dep.get_virtual_package_name() - if dep.is_azure_devops() and len(repo_parts) >= 3: - # ADO structure: org/project/virtual-pkg-name - declared_deps.add(f"{repo_parts[0]}/{repo_parts[1]}/{package_name}") - elif len(repo_parts) >= 2: - # GitHub structure: owner/virtual-pkg-name - declared_deps.add(f"{repo_parts[0]}/{package_name}") + if dep.is_virtual_subdirectory() and dep.virtual_path: + # Virtual subdirectory packages keep natural path structure. + # GitHub: owner/repo/subdir + # ADO: org/project/repo/subdir + if dep.is_azure_devops() and len(repo_parts) >= 3: + declared_deps.add( + f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}/{dep.virtual_path}" + ) + elif len(repo_parts) >= 2: + declared_deps.add( + f"{repo_parts[0]}/{repo_parts[1]}/{dep.virtual_path}" + ) + else: + # Virtual file/collection packages are flattened. + package_name = dep.get_virtual_package_name() + if dep.is_azure_devops() and len(repo_parts) >= 3: + # ADO structure: org/project/virtual-pkg-name + declared_deps.add(f"{repo_parts[0]}/{repo_parts[1]}/{package_name}") + elif len(repo_parts) >= 2: + # GitHub structure: owner/virtual-pkg-name + declared_deps.add(f"{repo_parts[0]}/{package_name}") else: # Regular package: use full repo_url path if dep.is_azure_devops() and len(repo_parts) >= 3: diff --git a/src/apm_cli/primitives/discovery.py b/src/apm_cli/primitives/discovery.py index bab4eed3..7f1a0f48 100644 --- a/src/apm_cli/primitives/discovery.py +++ b/src/apm_cli/primitives/discovery.py @@ -211,26 +211,40 @@ def get_dependency_declaration_order(base_dir: str) -> List[str]: apm_dependencies = package.get_apm_dependencies() # Extract installed paths from dependency references - # Virtual packages use get_virtual_package_name() for the final directory component + # Virtual file/collection packages use get_virtual_package_name() (flattened), + # while virtual subdirectory packages use natural repo/subdir paths. dependency_names = [] for dep in apm_dependencies: if dep.alias: dependency_names.append(dep.alias) elif dep.is_virtual: - # Virtual packages: construct path with virtual package name - # GitHub: owner/virtual-pkg-name - # ADO: org/project/virtual-pkg-name repo_parts = dep.repo_url.split("/") - virtual_name = dep.get_virtual_package_name() - if dep.is_azure_devops() and len(repo_parts) >= 3: - # ADO structure: org/project/virtual-pkg-name - dependency_names.append(f"{repo_parts[0]}/{repo_parts[1]}/{virtual_name}") - elif len(repo_parts) >= 2: - # GitHub structure: owner/virtual-pkg-name - dependency_names.append(f"{repo_parts[0]}/{virtual_name}") + + if dep.is_virtual_subdirectory() and dep.virtual_path: + # Virtual subdirectory packages keep natural path structure. + # GitHub: owner/repo/subdir + # ADO: org/project/repo/subdir + if dep.is_azure_devops() and len(repo_parts) >= 3: + dependency_names.append( + f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}/{dep.virtual_path}" + ) + elif len(repo_parts) >= 2: + dependency_names.append( + f"{repo_parts[0]}/{repo_parts[1]}/{dep.virtual_path}" + ) + else: + dependency_names.append(dep.virtual_path) else: - # Fallback - dependency_names.append(virtual_name) + # Virtual file/collection packages are flattened by package name. + # GitHub: owner/virtual-pkg-name + # ADO: org/project/virtual-pkg-name + virtual_name = dep.get_virtual_package_name() + if dep.is_azure_devops() and len(repo_parts) >= 3: + dependency_names.append(f"{repo_parts[0]}/{repo_parts[1]}/{virtual_name}") + elif len(repo_parts) >= 2: + dependency_names.append(f"{repo_parts[0]}/{virtual_name}") + else: + dependency_names.append(virtual_name) else: # Regular packages: use full org/repo path # This matches our org-namespaced directory structure diff --git a/tests/integration/test_virtual_package_orphan_detection.py b/tests/integration/test_virtual_package_orphan_detection.py index 7be9e24c..594740c0 100644 --- a/tests/integration/test_virtual_package_orphan_detection.py +++ b/tests/integration/test_virtual_package_orphan_detection.py @@ -31,13 +31,21 @@ def _build_expected_installed_packages(declared_deps): for dep in declared_deps: repo_parts = dep.repo_url.split('/') if dep.is_virtual: - package_name = dep.get_virtual_package_name() - if dep.is_azure_devops() and len(repo_parts) >= 3: - # ADO structure: org/project/virtual-pkg-name - expected_installed.add(f"{repo_parts[0]}/{repo_parts[1]}/{package_name}") - elif len(repo_parts) >= 2: - # GitHub structure: owner/virtual-pkg-name - expected_installed.add(f"{repo_parts[0]}/{package_name}") + if dep.is_virtual_subdirectory() and dep.virtual_path: + if dep.is_azure_devops() and len(repo_parts) >= 3: + # ADO structure: org/project/repo/subdir + expected_installed.add(f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}/{dep.virtual_path}") + elif len(repo_parts) >= 2: + # GitHub structure: owner/repo/subdir + expected_installed.add(f"{repo_parts[0]}/{repo_parts[1]}/{dep.virtual_path}") + else: + package_name = dep.get_virtual_package_name() + if dep.is_azure_devops() and len(repo_parts) >= 3: + # ADO structure: org/project/virtual-pkg-name + expected_installed.add(f"{repo_parts[0]}/{repo_parts[1]}/{package_name}") + elif len(repo_parts) >= 2: + # GitHub structure: owner/virtual-pkg-name + expected_installed.add(f"{repo_parts[0]}/{package_name}") else: if dep.is_azure_devops() and len(repo_parts) >= 3: # ADO structure: org/project/repo @@ -81,6 +89,28 @@ def _find_installed_packages(apm_modules_dir): return installed_packages +def _find_installed_subdirectory_packages(apm_modules_dir): + """Find installed virtual subdirectory packages at any nested depth. + + Returns relative paths for package roots that contain apm.yml or .apm + and are nested 3+ levels under apm_modules (owner/repo/subdir...). + """ + installed_subdirs = [] + if not apm_modules_dir.exists(): + return installed_subdirs + + for candidate in apm_modules_dir.rglob("*"): + if not candidate.is_dir() or candidate.name.startswith("."): + continue + if not ((candidate / "apm.yml").exists() or (candidate / ".apm").exists()): + continue + rel_parts = candidate.relative_to(apm_modules_dir).parts + if len(rel_parts) >= 3: + installed_subdirs.append("/".join(rel_parts)) + + return installed_subdirs + + def _find_orphaned_packages(project_dir): """Find orphaned packages in a project by comparing installed vs declared. @@ -416,3 +446,63 @@ def test_get_dependency_declaration_order_mixed_github_and_ado(tmp_path): assert dep_order[1] == "github/awesome-copilot-code-review" # GitHub virtual: owner/virtual-pkg-name assert dep_order[2] == "company/project/repo" # ADO regular: org/project/repo assert dep_order[3] == "company/my-azurecollection/copilot-instructions-csharp-ddd" # ADO virtual: org/project/virtual-pkg-name + + +@pytest.mark.integration +def test_virtual_subdirectory_not_flagged_as_orphan(tmp_path): + """Test that installed virtual subdirectory package is not flagged as orphaned.""" + project_dir = tmp_path / "test-project" + project_dir.mkdir() + + apm_yml_content = { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "apm": [ + "owner/repo/skills/azure-naming" + ] + } + } + + with open(project_dir / "apm.yml", "w") as f: + yaml.dump(apm_yml_content, f) + + # Simulate installed virtual subdirectory package at natural path: owner/repo/skills/azure-naming + subdir_pkg = project_dir / "apm_modules" / "owner" / "repo" / "skills" / "azure-naming" + subdir_pkg.mkdir(parents=True) + (subdir_pkg / "apm.yml").write_text("name: azure-naming\nversion: 1.0.0") + + orphaned_packages, expected_installed = _find_orphaned_packages(project_dir) + # Include 4-level detection for this test shape + installed_subdirs = _find_installed_subdirectory_packages(project_dir / "apm_modules") + for pkg in installed_subdirs: + if pkg not in expected_installed and pkg not in orphaned_packages: + orphaned_packages.append(pkg) + + assert "owner/repo/skills/azure-naming" in expected_installed + assert "owner/repo/skills/azure-naming" not in orphaned_packages + + +@pytest.mark.integration +def test_get_dependency_declaration_order_virtual_subdirectory(tmp_path): + """Test declaration order path for GitHub virtual subdirectory dependency.""" + project_dir = tmp_path / "test-project" + project_dir.mkdir() + + apm_yml_content = { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "apm": [ + "owner/repo/skills/azure-naming" + ] + } + } + + with open(project_dir / "apm.yml", "w") as f: + yaml.dump(apm_yml_content, f) + + dep_order = get_dependency_declaration_order(str(project_dir)) + + assert len(dep_order) == 1 + assert dep_order[0] == "owner/repo/skills/azure-naming" From ab5c66d227a3b32be634ae6596319c597d180a0f Mon Sep 17 00:00:00 2001 From: sergio-sisternes-epam Date: Tue, 24 Feb 2026 14:47:37 +0000 Subject: [PATCH 2/4] fix: address PR #100 review comments - scan_dependency_primitives: use Path.joinpath(*parts) to handle variable-length paths (4+ parts for nested sub-skills) - test helper: change threshold to >= 4 to avoid overlap with 3-level ADO packages already found by _find_installed_packages - test: simplify orphan detection with unified installed set instead of fragile patch-on-top approach Refs: #99 --- src/apm_cli/primitives/discovery.py | 13 +++---------- .../test_virtual_package_orphan_detection.py | 19 ++++++++++++------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/apm_cli/primitives/discovery.py b/src/apm_cli/primitives/discovery.py index 7f1a0f48..77c10fa2 100644 --- a/src/apm_cli/primitives/discovery.py +++ b/src/apm_cli/primitives/discovery.py @@ -171,19 +171,12 @@ def scan_dependency_primitives(base_dir: str, collection: PrimitiveCollection) - # Process dependencies in declaration order for dep_name in dependency_order: - # Handle org-namespaced structure + # Join all path parts to handle variable-length paths: # GitHub: "owner/repo" (2 parts) # Azure DevOps: "org/project/repo" (3 parts) + # Virtual subdirectory: "owner/repo/subdir" or deeper (3+ parts) parts = dep_name.split("/") - if len(parts) >= 3: - # ADO structure: apm_modules/org/project/repo - dep_path = apm_modules_path / parts[0] / parts[1] / parts[2] - elif len(parts) == 2: - # GitHub structure: apm_modules/owner/repo - dep_path = apm_modules_path / parts[0] / parts[1] - else: - # Fallback for non-namespaced dependencies - dep_path = apm_modules_path / dep_name + dep_path = apm_modules_path.joinpath(*parts) if dep_path.exists() and dep_path.is_dir(): scan_directory_with_source(dep_path, collection, source=f"dependency:{dep_name}") diff --git a/tests/integration/test_virtual_package_orphan_detection.py b/tests/integration/test_virtual_package_orphan_detection.py index 594740c0..799bae3b 100644 --- a/tests/integration/test_virtual_package_orphan_detection.py +++ b/tests/integration/test_virtual_package_orphan_detection.py @@ -105,7 +105,9 @@ def _find_installed_subdirectory_packages(apm_modules_dir): if not ((candidate / "apm.yml").exists() or (candidate / ".apm").exists()): continue rel_parts = candidate.relative_to(apm_modules_dir).parts - if len(rel_parts) >= 3: + # Only include paths deeper than the standard 3-level ADO structure + # (org/project/repo). Virtual subdirectory packages start at 4+ parts. + if len(rel_parts) >= 4: installed_subdirs.append("/".join(rel_parts)) return installed_subdirs @@ -472,12 +474,15 @@ def test_virtual_subdirectory_not_flagged_as_orphan(tmp_path): subdir_pkg.mkdir(parents=True) (subdir_pkg / "apm.yml").write_text("name: azure-naming\nversion: 1.0.0") - orphaned_packages, expected_installed = _find_orphaned_packages(project_dir) - # Include 4-level detection for this test shape - installed_subdirs = _find_installed_subdirectory_packages(project_dir / "apm_modules") - for pkg in installed_subdirs: - if pkg not in expected_installed and pkg not in orphaned_packages: - orphaned_packages.append(pkg) + # Build expected set from declared dependencies + package = APMPackage.from_apm_yml(project_dir / "apm.yml") + expected_installed = _build_expected_installed_packages(package.get_apm_dependencies()) + + # Compute a unified view of all installed packages (2-3 level + 4+ level) + installed_pkgs = set(_find_installed_packages(project_dir / "apm_modules")) + installed_pkgs.update(_find_installed_subdirectory_packages(project_dir / "apm_modules")) + + orphaned_packages = [pkg for pkg in installed_pkgs if pkg not in expected_installed] assert "owner/repo/skills/azure-naming" in expected_installed assert "owner/repo/skills/azure-naming" not in orphaned_packages From a70049930ea126a293dd73d222432db0e9b2c42e Mon Sep 17 00:00:00 2001 From: sergio-sisternes-epam Date: Tue, 24 Feb 2026 15:01:10 +0000 Subject: [PATCH 3/4] fix: prune and deps list handle virtual subdirectory packages - cli.py prune: add is_virtual_subdirectory() branch to expected_installed so prune won't incorrectly remove subdirectory packages - cli.py prune: replace 2-3 level scanner with rglob to discover packages at any depth (4+ levels for nested sub-skills) - deps.py list: replace 2-3 level scanner with rglob for same reason Both scanners now use rglob('*') to find any directory containing apm.yml, regardless of nesting depth, matching the install path structure. Refs: #99 --- src/apm_cli/cli.py | 66 ++++++++++++--------------- src/apm_cli/commands/deps.py | 88 +++++++++++++----------------------- 2 files changed, 61 insertions(+), 93 deletions(-) diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index d6949337..e81e85dc 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -762,57 +762,51 @@ def prune(ctx, dry_run): for dep in declared_deps: repo_parts = dep.repo_url.split("/") if dep.is_virtual: - # Virtual package: include full path based on platform - package_name = dep.get_virtual_package_name() - if dep.is_azure_devops() and len(repo_parts) >= 3: - # ADO structure: org/project/virtual-pkg-name - expected_installed.add( - f"{repo_parts[0]}/{repo_parts[1]}/{package_name}" - ) - elif len(repo_parts) >= 2: - # GitHub structure: owner/virtual-pkg-name - expected_installed.add(f"{repo_parts[0]}/{package_name}") + if dep.is_virtual_subdirectory() and dep.virtual_path: + # Virtual subdirectory packages keep natural path structure. + if dep.is_azure_devops() and len(repo_parts) >= 3: + expected_installed.add( + f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}/{dep.virtual_path}" + ) + elif len(repo_parts) >= 2: + expected_installed.add( + f"{repo_parts[0]}/{repo_parts[1]}/{dep.virtual_path}" + ) + else: + # Virtual file/collection packages are flattened. + package_name = dep.get_virtual_package_name() + if dep.is_azure_devops() and len(repo_parts) >= 3: + expected_installed.add( + f"{repo_parts[0]}/{repo_parts[1]}/{package_name}" + ) + elif len(repo_parts) >= 2: + expected_installed.add(f"{repo_parts[0]}/{package_name}") else: # Regular package: use full repo_url path if dep.is_azure_devops() and len(repo_parts) >= 3: - # ADO structure: org/project/repo expected_installed.add( f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}" ) elif len(repo_parts) >= 2: - # GitHub structure: owner/repo expected_installed.add(f"{repo_parts[0]}/{repo_parts[1]}") except Exception as e: _rich_error(f"Failed to parse apm.yml: {e}") sys.exit(1) # Find installed packages in apm_modules/ (now org-namespaced) - # GitHub: apm_modules/owner/repo (2 levels) - # Azure DevOps: apm_modules/org/project/repo (3 levels) + # Walks the tree to find directories containing apm.yml or .apm, + # handling GitHub (2-level), ADO (3-level), and subdirectory (4+ level) packages. installed_packages = {} # {"path": "display_name"} if apm_modules_dir.exists(): - for level1_dir in apm_modules_dir.iterdir(): - if level1_dir.is_dir() and not level1_dir.name.startswith("."): - for level2_dir in level1_dir.iterdir(): - if level2_dir.is_dir() and not level2_dir.name.startswith("."): - # Check if level2 has apm.yml (GitHub 2-level structure) - if (level2_dir / "apm.yml").exists() or ( - level2_dir / ".apm" - ).exists(): - path_key = f"{level1_dir.name}/{level2_dir.name}" - installed_packages[path_key] = path_key - else: - # Check for ADO 3-level structure - for level3_dir in level2_dir.iterdir(): - if ( - level3_dir.is_dir() - and not level3_dir.name.startswith(".") - ): - if (level3_dir / "apm.yml").exists() or ( - level3_dir / ".apm" - ).exists(): - path_key = f"{level1_dir.name}/{level2_dir.name}/{level3_dir.name}" - installed_packages[path_key] = path_key + for candidate in apm_modules_dir.rglob("*"): + if not candidate.is_dir() or candidate.name.startswith("."): + continue + if not ((candidate / "apm.yml").exists() or (candidate / ".apm").exists()): + continue + rel_parts = candidate.relative_to(apm_modules_dir).parts + if len(rel_parts) >= 2: + path_key = "/".join(rel_parts) + installed_packages[path_key] = path_key # Find orphaned packages (installed but not declared) orphaned_packages = {} diff --git a/src/apm_cli/commands/deps.py b/src/apm_cli/commands/deps.py index 19b54636..c1e3930b 100644 --- a/src/apm_cli/commands/deps.py +++ b/src/apm_cli/commands/deps.py @@ -94,65 +94,39 @@ def list_packages(): pass # Continue without orphan detection if apm.yml parsing fails # Scan for installed packages in org-namespaced structure - # GitHub: apm_modules/owner/repo (2 levels) - # Azure DevOps: apm_modules/org/project/repo (3 levels) + # Walks the tree to find directories containing apm.yml, + # handling GitHub (2-level), ADO (3-level), and subdirectory (4+ level) packages. installed_packages = [] orphaned_packages = [] - for level1_dir in apm_modules_path.iterdir(): - if level1_dir.is_dir() and not level1_dir.name.startswith('.'): - for level2_dir in level1_dir.iterdir(): - if level2_dir.is_dir() and not level2_dir.name.startswith('.'): - # Check if level2 has apm.yml (GitHub 2-level structure) - apm_yml_path = level2_dir / "apm.yml" - if apm_yml_path.exists(): - try: - # GitHub 2-level: org/repo format - org_repo_name = f"{level1_dir.name}/{level2_dir.name}" - package = APMPackage.from_apm_yml(apm_yml_path) - context_count, workflow_count = _count_package_files(level2_dir) - - is_orphaned = org_repo_name not in declared_deps - if is_orphaned: - orphaned_packages.append(org_repo_name) - - installed_packages.append({ - 'name': org_repo_name, - 'version': package.version or 'unknown', - 'source': 'orphaned' if is_orphaned else 'github', - 'context': context_count, - 'workflows': workflow_count, - 'path': str(level2_dir), - 'is_orphaned': is_orphaned - }) - except Exception as e: - click.echo(f"⚠️ Warning: Failed to read package {level1_dir.name}/{level2_dir.name}: {e}") - else: - # Check for ADO 3-level structure: org/project/repo - for level3_dir in level2_dir.iterdir(): - if level3_dir.is_dir() and not level3_dir.name.startswith('.'): - apm_yml_path = level3_dir / "apm.yml" - if apm_yml_path.exists(): - try: - # ADO 3-level: org/project/repo format - org_repo_name = f"{level1_dir.name}/{level2_dir.name}/{level3_dir.name}" - package = APMPackage.from_apm_yml(apm_yml_path) - context_count, workflow_count = _count_package_files(level3_dir) - - is_orphaned = org_repo_name not in declared_deps - if is_orphaned: - orphaned_packages.append(org_repo_name) - - installed_packages.append({ - 'name': org_repo_name, - 'version': package.version or 'unknown', - 'source': 'orphaned' if is_orphaned else 'azure-devops', - 'context': context_count, - 'workflows': workflow_count, - 'path': str(level3_dir), - 'is_orphaned': is_orphaned - }) - except Exception as e: - click.echo(f"⚠️ Warning: Failed to read package {level1_dir.name}/{level2_dir.name}/{level3_dir.name}: {e}") + for candidate in apm_modules_path.rglob("*"): + if not candidate.is_dir() or candidate.name.startswith('.'): + continue + apm_yml_path = candidate / "apm.yml" + if not apm_yml_path.exists(): + continue + rel_parts = candidate.relative_to(apm_modules_path).parts + if len(rel_parts) < 2: + continue + org_repo_name = "/".join(rel_parts) + try: + package = APMPackage.from_apm_yml(apm_yml_path) + context_count, workflow_count = _count_package_files(candidate) + + is_orphaned = org_repo_name not in declared_deps + if is_orphaned: + orphaned_packages.append(org_repo_name) + + installed_packages.append({ + 'name': org_repo_name, + 'version': package.version or 'unknown', + 'source': 'orphaned' if is_orphaned else 'github', + 'context': context_count, + 'workflows': workflow_count, + 'path': str(candidate), + 'is_orphaned': is_orphaned + }) + except Exception as e: + click.echo(f"⚠️ Warning: Failed to read package {org_repo_name}: {e}") if not installed_packages: if has_rich: From bede7f90f6d346f809b87793e40603350785014f Mon Sep 17 00:00:00 2001 From: sergio-sisternes-epam Date: Tue, 24 Feb 2026 16:25:51 +0000 Subject: [PATCH 4/4] fix: preserve ADO source label in rglob-based deps scanner The rglob refactor unified GitHub/ADO scanner branches but hardcoded 'github' as the source for all non-orphaned packages. This broke ADO packages which should show 'azure-devops'. Fix: replace declared_deps set with declared_sources dict that maps each dep path to its source label ('github' | 'azure-devops'), derived from dep.is_azure_devops(). The scanner looks up the source instead of hardcoding it. Refs: #99 --- src/apm_cli/commands/deps.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/apm_cli/commands/deps.py b/src/apm_cli/commands/deps.py index c1e3930b..3f369bd7 100644 --- a/src/apm_cli/commands/deps.py +++ b/src/apm_cli/commands/deps.py @@ -52,7 +52,7 @@ def list_packages(): # Load project dependencies to check for orphaned packages # GitHub: owner/repo or owner/virtual-pkg-name (2 levels) # Azure DevOps: org/project/repo or org/project/virtual-pkg-name (3 levels) - declared_deps = set() + declared_sources = {} # dep_path → 'github' | 'azure-devops' try: apm_yml_path = project_root / "apm.yml" if apm_yml_path.exists(): @@ -60,36 +60,37 @@ def list_packages(): for dep in project_package.get_apm_dependencies(): # Build the expected installed package name repo_parts = dep.repo_url.split('/') + source = 'azure-devops' if dep.is_azure_devops() else 'github' if dep.is_virtual: if dep.is_virtual_subdirectory() and dep.virtual_path: # Virtual subdirectory packages keep natural path structure. # GitHub: owner/repo/subdir # ADO: org/project/repo/subdir if dep.is_azure_devops() and len(repo_parts) >= 3: - declared_deps.add( + declared_sources[ f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}/{dep.virtual_path}" - ) + ] = source elif len(repo_parts) >= 2: - declared_deps.add( + declared_sources[ f"{repo_parts[0]}/{repo_parts[1]}/{dep.virtual_path}" - ) + ] = source else: # Virtual file/collection packages are flattened. package_name = dep.get_virtual_package_name() if dep.is_azure_devops() and len(repo_parts) >= 3: # ADO structure: org/project/virtual-pkg-name - declared_deps.add(f"{repo_parts[0]}/{repo_parts[1]}/{package_name}") + declared_sources[f"{repo_parts[0]}/{repo_parts[1]}/{package_name}"] = source elif len(repo_parts) >= 2: # GitHub structure: owner/virtual-pkg-name - declared_deps.add(f"{repo_parts[0]}/{package_name}") + declared_sources[f"{repo_parts[0]}/{package_name}"] = source else: # Regular package: use full repo_url path if dep.is_azure_devops() and len(repo_parts) >= 3: # ADO structure: org/project/repo - declared_deps.add(f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}") + declared_sources[f"{repo_parts[0]}/{repo_parts[1]}/{repo_parts[2]}"] = source elif len(repo_parts) >= 2: # GitHub structure: owner/repo - declared_deps.add(f"{repo_parts[0]}/{repo_parts[1]}") + declared_sources[f"{repo_parts[0]}/{repo_parts[1]}"] = source except Exception: pass # Continue without orphan detection if apm.yml parsing fails @@ -112,14 +113,14 @@ def list_packages(): package = APMPackage.from_apm_yml(apm_yml_path) context_count, workflow_count = _count_package_files(candidate) - is_orphaned = org_repo_name not in declared_deps + is_orphaned = org_repo_name not in declared_sources if is_orphaned: orphaned_packages.append(org_repo_name) installed_packages.append({ 'name': org_repo_name, 'version': package.version or 'unknown', - 'source': 'orphaned' if is_orphaned else 'github', + 'source': 'orphaned' if is_orphaned else declared_sources.get(org_repo_name, 'github'), 'context': context_count, 'workflows': workflow_count, 'path': str(candidate),