spec-kitty-cli 0.12.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
- spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
- spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
- spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
- spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
- specify_cli/__init__.py +171 -0
- specify_cli/acceptance.py +627 -0
- specify_cli/agent_utils/README.md +157 -0
- specify_cli/agent_utils/__init__.py +9 -0
- specify_cli/agent_utils/status.py +356 -0
- specify_cli/cli/__init__.py +6 -0
- specify_cli/cli/commands/__init__.py +46 -0
- specify_cli/cli/commands/accept.py +189 -0
- specify_cli/cli/commands/agent/__init__.py +22 -0
- specify_cli/cli/commands/agent/config.py +382 -0
- specify_cli/cli/commands/agent/context.py +191 -0
- specify_cli/cli/commands/agent/feature.py +1057 -0
- specify_cli/cli/commands/agent/release.py +11 -0
- specify_cli/cli/commands/agent/tasks.py +1253 -0
- specify_cli/cli/commands/agent/workflow.py +801 -0
- specify_cli/cli/commands/context.py +246 -0
- specify_cli/cli/commands/dashboard.py +85 -0
- specify_cli/cli/commands/implement.py +973 -0
- specify_cli/cli/commands/init.py +827 -0
- specify_cli/cli/commands/init_help.py +62 -0
- specify_cli/cli/commands/merge.py +755 -0
- specify_cli/cli/commands/mission.py +240 -0
- specify_cli/cli/commands/ops.py +265 -0
- specify_cli/cli/commands/orchestrate.py +640 -0
- specify_cli/cli/commands/repair.py +175 -0
- specify_cli/cli/commands/research.py +165 -0
- specify_cli/cli/commands/sync.py +364 -0
- specify_cli/cli/commands/upgrade.py +249 -0
- specify_cli/cli/commands/validate_encoding.py +186 -0
- specify_cli/cli/commands/validate_tasks.py +186 -0
- specify_cli/cli/commands/verify.py +310 -0
- specify_cli/cli/helpers.py +123 -0
- specify_cli/cli/step_tracker.py +91 -0
- specify_cli/cli/ui.py +192 -0
- specify_cli/core/__init__.py +53 -0
- specify_cli/core/agent_context.py +311 -0
- specify_cli/core/config.py +96 -0
- specify_cli/core/context_validation.py +362 -0
- specify_cli/core/dependency_graph.py +351 -0
- specify_cli/core/git_ops.py +129 -0
- specify_cli/core/multi_parent_merge.py +323 -0
- specify_cli/core/paths.py +260 -0
- specify_cli/core/project_resolver.py +110 -0
- specify_cli/core/stale_detection.py +263 -0
- specify_cli/core/tool_checker.py +79 -0
- specify_cli/core/utils.py +43 -0
- specify_cli/core/vcs/__init__.py +114 -0
- specify_cli/core/vcs/detection.py +341 -0
- specify_cli/core/vcs/exceptions.py +85 -0
- specify_cli/core/vcs/git.py +1304 -0
- specify_cli/core/vcs/jujutsu.py +1208 -0
- specify_cli/core/vcs/protocol.py +285 -0
- specify_cli/core/vcs/types.py +249 -0
- specify_cli/core/version_checker.py +261 -0
- specify_cli/core/worktree.py +506 -0
- specify_cli/dashboard/__init__.py +28 -0
- specify_cli/dashboard/diagnostics.py +204 -0
- specify_cli/dashboard/handlers/__init__.py +17 -0
- specify_cli/dashboard/handlers/api.py +143 -0
- specify_cli/dashboard/handlers/base.py +65 -0
- specify_cli/dashboard/handlers/features.py +390 -0
- specify_cli/dashboard/handlers/router.py +81 -0
- specify_cli/dashboard/handlers/static.py +50 -0
- specify_cli/dashboard/lifecycle.py +541 -0
- specify_cli/dashboard/scanner.py +437 -0
- specify_cli/dashboard/server.py +123 -0
- specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
- specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
- specify_cli/dashboard/static/spec-kitty.png +0 -0
- specify_cli/dashboard/templates/__init__.py +36 -0
- specify_cli/dashboard/templates/index.html +258 -0
- specify_cli/doc_generators.py +621 -0
- specify_cli/doc_state.py +408 -0
- specify_cli/frontmatter.py +384 -0
- specify_cli/gap_analysis.py +915 -0
- specify_cli/gitignore_manager.py +300 -0
- specify_cli/guards.py +145 -0
- specify_cli/legacy_detector.py +83 -0
- specify_cli/manifest.py +286 -0
- specify_cli/merge/__init__.py +63 -0
- specify_cli/merge/executor.py +653 -0
- specify_cli/merge/forecast.py +215 -0
- specify_cli/merge/ordering.py +126 -0
- specify_cli/merge/preflight.py +230 -0
- specify_cli/merge/state.py +185 -0
- specify_cli/merge/status_resolver.py +354 -0
- specify_cli/mission.py +654 -0
- specify_cli/missions/documentation/command-templates/implement.md +309 -0
- specify_cli/missions/documentation/command-templates/plan.md +275 -0
- specify_cli/missions/documentation/command-templates/review.md +344 -0
- specify_cli/missions/documentation/command-templates/specify.md +206 -0
- specify_cli/missions/documentation/command-templates/tasks.md +189 -0
- specify_cli/missions/documentation/mission.yaml +113 -0
- specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
- specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
- specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
- specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
- specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
- specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
- specify_cli/missions/documentation/templates/plan-template.md +269 -0
- specify_cli/missions/documentation/templates/release-template.md +222 -0
- specify_cli/missions/documentation/templates/spec-template.md +172 -0
- specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
- specify_cli/missions/documentation/templates/tasks-template.md +159 -0
- specify_cli/missions/research/command-templates/merge.md +388 -0
- specify_cli/missions/research/command-templates/plan.md +125 -0
- specify_cli/missions/research/command-templates/review.md +144 -0
- specify_cli/missions/research/command-templates/tasks.md +225 -0
- specify_cli/missions/research/mission.yaml +115 -0
- specify_cli/missions/research/templates/data-model-template.md +33 -0
- specify_cli/missions/research/templates/plan-template.md +161 -0
- specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
- specify_cli/missions/research/templates/research/source-register.csv +18 -0
- specify_cli/missions/research/templates/research-template.md +35 -0
- specify_cli/missions/research/templates/spec-template.md +64 -0
- specify_cli/missions/research/templates/task-prompt-template.md +148 -0
- specify_cli/missions/research/templates/tasks-template.md +114 -0
- specify_cli/missions/software-dev/command-templates/accept.md +75 -0
- specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
- specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
- specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
- specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
- specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
- specify_cli/missions/software-dev/command-templates/implement.md +41 -0
- specify_cli/missions/software-dev/command-templates/merge.md +383 -0
- specify_cli/missions/software-dev/command-templates/plan.md +171 -0
- specify_cli/missions/software-dev/command-templates/review.md +32 -0
- specify_cli/missions/software-dev/command-templates/specify.md +321 -0
- specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
- specify_cli/missions/software-dev/mission.yaml +100 -0
- specify_cli/missions/software-dev/templates/plan-template.md +132 -0
- specify_cli/missions/software-dev/templates/spec-template.md +116 -0
- specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
- specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
- specify_cli/orchestrator/__init__.py +75 -0
- specify_cli/orchestrator/agent_config.py +224 -0
- specify_cli/orchestrator/agents/__init__.py +170 -0
- specify_cli/orchestrator/agents/augment.py +112 -0
- specify_cli/orchestrator/agents/base.py +243 -0
- specify_cli/orchestrator/agents/claude.py +112 -0
- specify_cli/orchestrator/agents/codex.py +106 -0
- specify_cli/orchestrator/agents/copilot.py +137 -0
- specify_cli/orchestrator/agents/cursor.py +139 -0
- specify_cli/orchestrator/agents/gemini.py +115 -0
- specify_cli/orchestrator/agents/kilocode.py +94 -0
- specify_cli/orchestrator/agents/opencode.py +132 -0
- specify_cli/orchestrator/agents/qwen.py +96 -0
- specify_cli/orchestrator/config.py +455 -0
- specify_cli/orchestrator/executor.py +642 -0
- specify_cli/orchestrator/integration.py +1230 -0
- specify_cli/orchestrator/monitor.py +898 -0
- specify_cli/orchestrator/scheduler.py +832 -0
- specify_cli/orchestrator/state.py +508 -0
- specify_cli/orchestrator/testing/__init__.py +122 -0
- specify_cli/orchestrator/testing/availability.py +346 -0
- specify_cli/orchestrator/testing/fixtures.py +684 -0
- specify_cli/orchestrator/testing/paths.py +218 -0
- specify_cli/plan_validation.py +107 -0
- specify_cli/scripts/debug-dashboard-scan.py +61 -0
- specify_cli/scripts/tasks/acceptance_support.py +695 -0
- specify_cli/scripts/tasks/task_helpers.py +506 -0
- specify_cli/scripts/tasks/tasks_cli.py +848 -0
- specify_cli/scripts/validate_encoding.py +180 -0
- specify_cli/task_metadata_validation.py +274 -0
- specify_cli/tasks_support.py +447 -0
- specify_cli/template/__init__.py +47 -0
- specify_cli/template/asset_generator.py +206 -0
- specify_cli/template/github_client.py +334 -0
- specify_cli/template/manager.py +193 -0
- specify_cli/template/renderer.py +99 -0
- specify_cli/templates/AGENTS.md +190 -0
- specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
- specify_cli/templates/agent-file-template.md +35 -0
- specify_cli/templates/checklist-template.md +42 -0
- specify_cli/templates/claudeignore-template +58 -0
- specify_cli/templates/command-templates/accept.md +141 -0
- specify_cli/templates/command-templates/analyze.md +253 -0
- specify_cli/templates/command-templates/checklist.md +352 -0
- specify_cli/templates/command-templates/clarify.md +224 -0
- specify_cli/templates/command-templates/constitution.md +432 -0
- specify_cli/templates/command-templates/dashboard.md +175 -0
- specify_cli/templates/command-templates/implement.md +190 -0
- specify_cli/templates/command-templates/merge.md +374 -0
- specify_cli/templates/command-templates/plan.md +171 -0
- specify_cli/templates/command-templates/research.md +88 -0
- specify_cli/templates/command-templates/review.md +510 -0
- specify_cli/templates/command-templates/specify.md +321 -0
- specify_cli/templates/command-templates/status.md +92 -0
- specify_cli/templates/command-templates/tasks.md +199 -0
- specify_cli/templates/git-hooks/pre-commit +22 -0
- specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
- specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
- specify_cli/templates/plan-template.md +108 -0
- specify_cli/templates/spec-template.md +118 -0
- specify_cli/templates/task-prompt-template.md +165 -0
- specify_cli/templates/tasks-template.md +161 -0
- specify_cli/templates/vscode-settings.json +13 -0
- specify_cli/text_sanitization.py +225 -0
- specify_cli/upgrade/__init__.py +18 -0
- specify_cli/upgrade/detector.py +239 -0
- specify_cli/upgrade/metadata.py +182 -0
- specify_cli/upgrade/migrations/__init__.py +65 -0
- specify_cli/upgrade/migrations/base.py +80 -0
- specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
- specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
- specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
- specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
- specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
- specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
- specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
- specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
- specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
- specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
- specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
- specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
- specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
- specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
- specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
- specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
- specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
- specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
- specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
- specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
- specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
- specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
- specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
- specify_cli/upgrade/registry.py +121 -0
- specify_cli/upgrade/runner.py +284 -0
- specify_cli/validators/__init__.py +14 -0
- specify_cli/validators/paths.py +154 -0
- specify_cli/validators/research.py +428 -0
- specify_cli/verify_enhanced.py +270 -0
- specify_cli/workspace_context.py +224 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Context validation for location-aware commands.
|
|
2
|
+
|
|
3
|
+
This module provides runtime validation to ensure commands are executed
|
|
4
|
+
in the correct location (main repository vs worktree). Prevents common
|
|
5
|
+
mistakes like running 'implement' from inside a worktree.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from specify_cli.core.context_validation import (
|
|
9
|
+
require_main_repo,
|
|
10
|
+
require_worktree,
|
|
11
|
+
get_current_context,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
@require_main_repo
|
|
15
|
+
def implement(wp_id: str):
|
|
16
|
+
# This function can only run from main repo
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
@require_worktree
|
|
20
|
+
def some_workspace_command():
|
|
21
|
+
# This function can only run from inside a worktree
|
|
22
|
+
pass
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import functools
|
|
28
|
+
import os
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from enum import Enum
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Callable, TypeVar
|
|
33
|
+
|
|
34
|
+
import typer
|
|
35
|
+
from rich.console import Console
|
|
36
|
+
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExecutionContext(str, Enum):
|
|
41
|
+
"""Execution context for a command."""
|
|
42
|
+
|
|
43
|
+
MAIN_REPO = "main" # Command runs in main repository
|
|
44
|
+
WORKTREE = "worktree" # Command runs inside a worktree
|
|
45
|
+
EITHER = "either" # Command can run in either location
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class CurrentContext:
|
|
50
|
+
"""Current execution context information."""
|
|
51
|
+
|
|
52
|
+
location: ExecutionContext
|
|
53
|
+
cwd: Path
|
|
54
|
+
repo_root: Path | None
|
|
55
|
+
worktree_name: str | None # e.g., "010-feature-WP02" if in worktree
|
|
56
|
+
worktree_path: Path | None # Absolute path to worktree directory
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def detect_execution_context(cwd: Path | None = None) -> CurrentContext:
|
|
60
|
+
"""Detect current execution context.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
cwd: Current working directory (defaults to Path.cwd())
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
CurrentContext with location, paths, and worktree info
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
>>> ctx = detect_execution_context()
|
|
70
|
+
>>> if ctx.location == ExecutionContext.WORKTREE:
|
|
71
|
+
... print(f"In worktree: {ctx.worktree_name}")
|
|
72
|
+
"""
|
|
73
|
+
if cwd is None:
|
|
74
|
+
cwd = Path.cwd().resolve()
|
|
75
|
+
else:
|
|
76
|
+
cwd = cwd.resolve()
|
|
77
|
+
|
|
78
|
+
# Check if .worktrees is in path
|
|
79
|
+
if ".worktrees" in cwd.parts:
|
|
80
|
+
# Extract worktree information
|
|
81
|
+
for i, part in enumerate(cwd.parts):
|
|
82
|
+
if part == ".worktrees" and i + 1 < len(cwd.parts):
|
|
83
|
+
worktree_name = cwd.parts[i + 1]
|
|
84
|
+
worktree_path = Path(*cwd.parts[: i + 2])
|
|
85
|
+
repo_root = Path(*cwd.parts[:i])
|
|
86
|
+
|
|
87
|
+
return CurrentContext(
|
|
88
|
+
location=ExecutionContext.WORKTREE,
|
|
89
|
+
cwd=cwd,
|
|
90
|
+
repo_root=repo_root,
|
|
91
|
+
worktree_name=worktree_name,
|
|
92
|
+
worktree_path=worktree_path,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Not in worktree - assume main repo
|
|
96
|
+
# Try to find repo root (directory containing .kittify or .git)
|
|
97
|
+
repo_root = None
|
|
98
|
+
search_path = cwd
|
|
99
|
+
for _ in range(10): # Limit depth
|
|
100
|
+
if (search_path / ".kittify").exists() or (search_path / ".git").exists():
|
|
101
|
+
repo_root = search_path
|
|
102
|
+
break
|
|
103
|
+
if search_path.parent == search_path:
|
|
104
|
+
break # Reached filesystem root
|
|
105
|
+
search_path = search_path.parent
|
|
106
|
+
|
|
107
|
+
return CurrentContext(
|
|
108
|
+
location=ExecutionContext.MAIN_REPO,
|
|
109
|
+
cwd=cwd,
|
|
110
|
+
repo_root=repo_root,
|
|
111
|
+
worktree_name=None,
|
|
112
|
+
worktree_path=None,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_current_context() -> CurrentContext:
|
|
117
|
+
"""Get current execution context.
|
|
118
|
+
|
|
119
|
+
Convenience function that detects context from current working directory.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
CurrentContext with location and path information
|
|
123
|
+
"""
|
|
124
|
+
return detect_execution_context()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def format_location_error(
|
|
128
|
+
required: ExecutionContext,
|
|
129
|
+
actual: ExecutionContext,
|
|
130
|
+
command_name: str,
|
|
131
|
+
current_ctx: CurrentContext,
|
|
132
|
+
) -> str:
|
|
133
|
+
"""Format a clear error message for location mismatch.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
required: Required execution context
|
|
137
|
+
actual: Actual execution context
|
|
138
|
+
command_name: Name of command being run
|
|
139
|
+
current_ctx: Current context information
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Formatted error message with actionable instructions
|
|
143
|
+
"""
|
|
144
|
+
if required == ExecutionContext.MAIN_REPO and actual == ExecutionContext.WORKTREE:
|
|
145
|
+
# Command needs main repo, but in worktree
|
|
146
|
+
if current_ctx.repo_root:
|
|
147
|
+
return (
|
|
148
|
+
f"[bold red]Error:[/bold red] '{command_name}' must run from the main repository\n\n"
|
|
149
|
+
f"[yellow]Current location:[/yellow] Inside worktree [cyan]{current_ctx.worktree_name}[/cyan]\n"
|
|
150
|
+
f"[yellow]Required location:[/yellow] Main repository\n\n"
|
|
151
|
+
f"[bold]Change to main repository:[/bold]\n"
|
|
152
|
+
f" cd {current_ctx.repo_root}\n\n"
|
|
153
|
+
f"[dim]This command creates/manages worktrees and must run from the main repository.\n"
|
|
154
|
+
f"Running from inside a worktree would create nested worktrees, corrupting git state.[/dim]"
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
return (
|
|
158
|
+
f"[bold red]Error:[/bold red] '{command_name}' must run from the main repository\n\n"
|
|
159
|
+
f"[yellow]Current location:[/yellow] Inside worktree [cyan]{current_ctx.worktree_name}[/cyan]\n"
|
|
160
|
+
f"[yellow]Required location:[/yellow] Main repository\n\n"
|
|
161
|
+
f"[bold]Change to main repository:[/bold]\n"
|
|
162
|
+
f" cd ../.. # Navigate up from worktree\n\n"
|
|
163
|
+
f"[dim]This command must run from the main repository.[/dim]"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
elif required == ExecutionContext.WORKTREE and actual == ExecutionContext.MAIN_REPO:
|
|
167
|
+
# Command needs worktree, but in main repo
|
|
168
|
+
return (
|
|
169
|
+
f"[bold red]Error:[/bold red] '{command_name}' must run from inside a worktree\n\n"
|
|
170
|
+
f"[yellow]Current location:[/yellow] Main repository\n"
|
|
171
|
+
f"[yellow]Required location:[/yellow] Inside a worktree\n\n"
|
|
172
|
+
f"[bold]Change to a worktree:[/bold]\n"
|
|
173
|
+
f" cd .worktrees/###-feature-WP##/\n\n"
|
|
174
|
+
f"[dim]This command operates on workspace files and must run from inside a worktree.[/dim]"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
else:
|
|
178
|
+
# Generic error
|
|
179
|
+
return (
|
|
180
|
+
f"[bold red]Error:[/bold red] '{command_name}' cannot run in current location\n\n"
|
|
181
|
+
f"[yellow]Current location:[/yellow] {actual.value}\n"
|
|
182
|
+
f"[yellow]Required location:[/yellow] {required.value}\n"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Type variable for function decoration
|
|
187
|
+
F = TypeVar("F", bound=Callable)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def require_main_repo(func: F) -> F:
|
|
191
|
+
"""Decorator to require command runs from main repository.
|
|
192
|
+
|
|
193
|
+
Prevents commands from running inside worktrees, which could cause
|
|
194
|
+
nested worktrees or other git corruption.
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
@require_main_repo
|
|
198
|
+
def implement(wp_id: str):
|
|
199
|
+
# Can only run from main repo
|
|
200
|
+
create_worktree(...)
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
func: Function to decorate
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Decorated function that validates location before executing
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
@functools.wraps(func)
|
|
210
|
+
def wrapper(*args, **kwargs):
|
|
211
|
+
ctx = get_current_context()
|
|
212
|
+
|
|
213
|
+
if ctx.location == ExecutionContext.WORKTREE:
|
|
214
|
+
error_msg = format_location_error(
|
|
215
|
+
required=ExecutionContext.MAIN_REPO,
|
|
216
|
+
actual=ctx.location,
|
|
217
|
+
command_name=func.__name__,
|
|
218
|
+
current_ctx=ctx,
|
|
219
|
+
)
|
|
220
|
+
console.print(error_msg)
|
|
221
|
+
raise typer.Exit(1)
|
|
222
|
+
|
|
223
|
+
return func(*args, **kwargs)
|
|
224
|
+
|
|
225
|
+
return wrapper # type: ignore
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def require_worktree(func: F) -> F:
|
|
229
|
+
"""Decorator to require command runs from inside a worktree.
|
|
230
|
+
|
|
231
|
+
Prevents commands from running in main repo when they need workspace context.
|
|
232
|
+
|
|
233
|
+
Example:
|
|
234
|
+
@require_worktree
|
|
235
|
+
def workspace_status():
|
|
236
|
+
# Can only run from inside worktree
|
|
237
|
+
show_workspace_info(...)
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
func: Function to decorate
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Decorated function that validates location before executing
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
@functools.wraps(func)
|
|
247
|
+
def wrapper(*args, **kwargs):
|
|
248
|
+
ctx = get_current_context()
|
|
249
|
+
|
|
250
|
+
if ctx.location == ExecutionContext.MAIN_REPO:
|
|
251
|
+
error_msg = format_location_error(
|
|
252
|
+
required=ExecutionContext.WORKTREE,
|
|
253
|
+
actual=ctx.location,
|
|
254
|
+
command_name=func.__name__,
|
|
255
|
+
current_ctx=ctx,
|
|
256
|
+
)
|
|
257
|
+
console.print(error_msg)
|
|
258
|
+
raise typer.Exit(1)
|
|
259
|
+
|
|
260
|
+
return func(*args, **kwargs)
|
|
261
|
+
|
|
262
|
+
return wrapper # type: ignore
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def require_either(func: F) -> F:
|
|
266
|
+
"""Decorator for commands that can run in either location.
|
|
267
|
+
|
|
268
|
+
This is primarily for documentation - the decorator doesn't enforce
|
|
269
|
+
anything, just marks the function as location-agnostic.
|
|
270
|
+
|
|
271
|
+
Example:
|
|
272
|
+
@require_either
|
|
273
|
+
def status():
|
|
274
|
+
# Can run from main repo or worktree
|
|
275
|
+
ctx = get_current_context()
|
|
276
|
+
if ctx.location == ExecutionContext.WORKTREE:
|
|
277
|
+
show_worktree_status()
|
|
278
|
+
else:
|
|
279
|
+
show_main_repo_status()
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
func: Function to decorate
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Original function (no validation added)
|
|
286
|
+
"""
|
|
287
|
+
return func
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def set_context_env_vars(ctx: CurrentContext) -> None:
|
|
291
|
+
"""Set environment variables for current context.
|
|
292
|
+
|
|
293
|
+
Makes context information available to subprocesses and scripts.
|
|
294
|
+
|
|
295
|
+
Environment variables set:
|
|
296
|
+
SPEC_KITTY_CONTEXT: "main" or "worktree"
|
|
297
|
+
SPEC_KITTY_CWD: Current working directory
|
|
298
|
+
SPEC_KITTY_REPO_ROOT: Repository root (if detected)
|
|
299
|
+
SPEC_KITTY_WORKTREE_NAME: Worktree name (if in worktree)
|
|
300
|
+
SPEC_KITTY_WORKTREE_PATH: Worktree path (if in worktree)
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
ctx: Current context information
|
|
304
|
+
|
|
305
|
+
Example:
|
|
306
|
+
>>> ctx = get_current_context()
|
|
307
|
+
>>> set_context_env_vars(ctx)
|
|
308
|
+
>>> print(os.environ.get("SPEC_KITTY_CONTEXT"))
|
|
309
|
+
"main"
|
|
310
|
+
"""
|
|
311
|
+
os.environ["SPEC_KITTY_CONTEXT"] = ctx.location.value
|
|
312
|
+
os.environ["SPEC_KITTY_CWD"] = str(ctx.cwd)
|
|
313
|
+
|
|
314
|
+
if ctx.repo_root:
|
|
315
|
+
os.environ["SPEC_KITTY_REPO_ROOT"] = str(ctx.repo_root)
|
|
316
|
+
else:
|
|
317
|
+
os.environ.pop("SPEC_KITTY_REPO_ROOT", None)
|
|
318
|
+
|
|
319
|
+
if ctx.worktree_name:
|
|
320
|
+
os.environ["SPEC_KITTY_WORKTREE_NAME"] = ctx.worktree_name
|
|
321
|
+
else:
|
|
322
|
+
os.environ.pop("SPEC_KITTY_WORKTREE_NAME", None)
|
|
323
|
+
|
|
324
|
+
if ctx.worktree_path:
|
|
325
|
+
os.environ["SPEC_KITTY_WORKTREE_PATH"] = str(ctx.worktree_path)
|
|
326
|
+
else:
|
|
327
|
+
os.environ.pop("SPEC_KITTY_WORKTREE_PATH", None)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def get_context_env_vars() -> dict[str, str]:
|
|
331
|
+
"""Get current context environment variables.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Dictionary of context environment variables
|
|
335
|
+
"""
|
|
336
|
+
env_vars = {}
|
|
337
|
+
|
|
338
|
+
for key in [
|
|
339
|
+
"SPEC_KITTY_CONTEXT",
|
|
340
|
+
"SPEC_KITTY_CWD",
|
|
341
|
+
"SPEC_KITTY_REPO_ROOT",
|
|
342
|
+
"SPEC_KITTY_WORKTREE_NAME",
|
|
343
|
+
"SPEC_KITTY_WORKTREE_PATH",
|
|
344
|
+
]:
|
|
345
|
+
value = os.environ.get(key)
|
|
346
|
+
if value:
|
|
347
|
+
env_vars[key] = value
|
|
348
|
+
|
|
349
|
+
return env_vars
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
__all__ = [
|
|
353
|
+
"ExecutionContext",
|
|
354
|
+
"CurrentContext",
|
|
355
|
+
"detect_execution_context",
|
|
356
|
+
"get_current_context",
|
|
357
|
+
"require_main_repo",
|
|
358
|
+
"require_worktree",
|
|
359
|
+
"require_either",
|
|
360
|
+
"set_context_env_vars",
|
|
361
|
+
"get_context_env_vars",
|
|
362
|
+
]
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Dependency graph utilities for work package relationships.
|
|
2
|
+
|
|
3
|
+
This module provides functions for parsing, validating, and analyzing
|
|
4
|
+
dependency relationships between work packages in Spec Kitty features.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from specify_cli.frontmatter import FrontmatterError, read_frontmatter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_wp_dependencies(wp_file: Path) -> list[str]:
|
|
17
|
+
"""Parse dependencies from WP frontmatter.
|
|
18
|
+
|
|
19
|
+
Uses FrontmatterManager for consistent parsing across CLI.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
wp_file: Path to work package markdown file
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
List of WP IDs this WP depends on (e.g., ["WP01", "WP02"])
|
|
26
|
+
Returns empty list if no dependencies or parsing fails
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> wp_file = Path("tasks/WP02.md")
|
|
30
|
+
>>> deps = parse_wp_dependencies(wp_file)
|
|
31
|
+
>>> print(deps) # ["WP01"]
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
# Use FrontmatterManager for consistent parsing
|
|
35
|
+
frontmatter, _ = read_frontmatter(wp_file)
|
|
36
|
+
|
|
37
|
+
# Extract dependencies field (FrontmatterManager already defaults to [])
|
|
38
|
+
dependencies = frontmatter.get("dependencies", [])
|
|
39
|
+
|
|
40
|
+
# Validate dependencies is a list
|
|
41
|
+
if not isinstance(dependencies, list):
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
return dependencies
|
|
45
|
+
|
|
46
|
+
except (FrontmatterError, OSError):
|
|
47
|
+
# Return empty list on any parsing error
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_dependency_graph(feature_dir: Path) -> dict[str, list[str]]:
|
|
52
|
+
"""Build dependency graph from all WPs in feature.
|
|
53
|
+
|
|
54
|
+
Scans tasks/ directory for WP files and parses their dependencies.
|
|
55
|
+
Validates that filename WP ID matches frontmatter work_package_id.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
feature_dir: Path to feature directory (contains tasks/ subdirectory)
|
|
59
|
+
OR path to tasks directory directly
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Adjacency list mapping WP ID to list of dependencies
|
|
63
|
+
Example: {"WP01": [], "WP02": ["WP01"], "WP03": ["WP01"]}
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
>>> feature_dir = Path("kitty-specs/010-feature")
|
|
67
|
+
>>> graph = build_dependency_graph(feature_dir)
|
|
68
|
+
>>> print(graph) # {"WP01": [], "WP02": ["WP01"]}
|
|
69
|
+
"""
|
|
70
|
+
graph = {}
|
|
71
|
+
|
|
72
|
+
# Support both feature_dir and tasks_dir as input
|
|
73
|
+
if feature_dir.name == "tasks":
|
|
74
|
+
# Already pointing to tasks directory
|
|
75
|
+
tasks_dir = feature_dir
|
|
76
|
+
else:
|
|
77
|
+
# Pointing to feature directory, append tasks/
|
|
78
|
+
tasks_dir = feature_dir / "tasks"
|
|
79
|
+
|
|
80
|
+
if not tasks_dir.exists():
|
|
81
|
+
return graph
|
|
82
|
+
|
|
83
|
+
# Find all WP markdown files
|
|
84
|
+
for wp_file in sorted(tasks_dir.glob("WP*.md")):
|
|
85
|
+
# Extract WP ID from filename (e.g., WP01-title.md → WP01)
|
|
86
|
+
filename_wp_id = extract_wp_id_from_filename(wp_file.name)
|
|
87
|
+
if not filename_wp_id:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
# Parse frontmatter to get canonical work_package_id
|
|
91
|
+
try:
|
|
92
|
+
frontmatter, _ = read_frontmatter(wp_file)
|
|
93
|
+
frontmatter_wp_id = frontmatter.get("work_package_id")
|
|
94
|
+
|
|
95
|
+
# Verify filename matches frontmatter (catch misnamed files)
|
|
96
|
+
if frontmatter_wp_id and frontmatter_wp_id != filename_wp_id:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"WP ID mismatch: filename {filename_wp_id} vs frontmatter {frontmatter_wp_id} "
|
|
99
|
+
f"in {wp_file}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
wp_id = frontmatter_wp_id or filename_wp_id
|
|
103
|
+
|
|
104
|
+
except (FrontmatterError, OSError):
|
|
105
|
+
# If frontmatter read fails, skip this file
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# Parse dependencies from frontmatter
|
|
109
|
+
dependencies = parse_wp_dependencies(wp_file)
|
|
110
|
+
graph[wp_id] = dependencies
|
|
111
|
+
|
|
112
|
+
return graph
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def extract_wp_id_from_filename(filename: str) -> Optional[str]:
|
|
116
|
+
"""Extract WP ID from filename.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
filename: WP file name (e.g., "WP01-title.md" or "WP02.md")
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
WP ID (e.g., "WP01") or None if invalid format
|
|
123
|
+
|
|
124
|
+
Examples:
|
|
125
|
+
>>> extract_wp_id_from_filename("WP01-setup.md")
|
|
126
|
+
'WP01'
|
|
127
|
+
>>> extract_wp_id_from_filename("invalid.md")
|
|
128
|
+
None
|
|
129
|
+
"""
|
|
130
|
+
match = re.match(r"^(WP\d{2})", filename)
|
|
131
|
+
return match.group(1) if match else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def detect_cycles(graph: dict[str, list[str]]) -> list[list[str]] | None:
|
|
135
|
+
"""Detect circular dependencies using DFS with coloring.
|
|
136
|
+
|
|
137
|
+
Uses depth-first search with three-color marking (white/gray/black)
|
|
138
|
+
to detect back edges, which indicate cycles.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
graph: Adjacency list mapping WP ID to dependencies
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
List of cycles (each cycle is a list of WP IDs) or None if acyclic
|
|
145
|
+
|
|
146
|
+
Complexity:
|
|
147
|
+
O(V + E) where V = vertices (WPs), E = edges (dependencies)
|
|
148
|
+
|
|
149
|
+
Examples:
|
|
150
|
+
>>> graph = {"WP01": ["WP02"], "WP02": ["WP01"]}
|
|
151
|
+
>>> cycles = detect_cycles(graph)
|
|
152
|
+
>>> print(cycles) # [["WP01", "WP02", "WP01"]]
|
|
153
|
+
|
|
154
|
+
>>> graph = {"WP01": [], "WP02": ["WP01"]}
|
|
155
|
+
>>> cycles = detect_cycles(graph)
|
|
156
|
+
>>> print(cycles) # None (acyclic)
|
|
157
|
+
"""
|
|
158
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
159
|
+
color = {wp: WHITE for wp in graph}
|
|
160
|
+
cycles = []
|
|
161
|
+
|
|
162
|
+
def dfs(node: str, path: list[str]) -> None:
|
|
163
|
+
"""DFS traversal with cycle detection."""
|
|
164
|
+
color[node] = GRAY
|
|
165
|
+
path.append(node)
|
|
166
|
+
|
|
167
|
+
for neighbor in graph.get(node, []):
|
|
168
|
+
neighbor_color = color.get(neighbor, WHITE)
|
|
169
|
+
|
|
170
|
+
if neighbor_color == GRAY:
|
|
171
|
+
# Back edge found - cycle detected
|
|
172
|
+
if neighbor in path:
|
|
173
|
+
cycle_start = path.index(neighbor)
|
|
174
|
+
cycles.append(path[cycle_start:] + [neighbor])
|
|
175
|
+
elif neighbor_color == WHITE:
|
|
176
|
+
dfs(neighbor, path)
|
|
177
|
+
|
|
178
|
+
path.pop()
|
|
179
|
+
color[node] = BLACK
|
|
180
|
+
|
|
181
|
+
# Run DFS from each unvisited node
|
|
182
|
+
for wp in graph:
|
|
183
|
+
if color[wp] == WHITE:
|
|
184
|
+
dfs(wp, [])
|
|
185
|
+
|
|
186
|
+
return cycles if cycles else None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def validate_dependencies(
|
|
190
|
+
wp_id: str,
|
|
191
|
+
declared_deps: list[str],
|
|
192
|
+
graph: dict[str, list[str]]
|
|
193
|
+
) -> tuple[bool, list[str]]:
|
|
194
|
+
"""Validate that WP's dependencies are valid.
|
|
195
|
+
|
|
196
|
+
Checks:
|
|
197
|
+
- Dependencies exist in graph
|
|
198
|
+
- No self-dependencies
|
|
199
|
+
- No circular dependencies
|
|
200
|
+
- Valid WP ID format
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
wp_id: Work package ID being validated
|
|
204
|
+
declared_deps: List of dependency WP IDs
|
|
205
|
+
graph: Complete dependency graph
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Tuple of (is_valid, error_messages)
|
|
209
|
+
- is_valid: True if all validations pass
|
|
210
|
+
- error_messages: List of error descriptions (empty if valid)
|
|
211
|
+
|
|
212
|
+
Examples:
|
|
213
|
+
>>> graph = {"WP01": [], "WP02": ["WP01"]}
|
|
214
|
+
>>> is_valid, errors = validate_dependencies("WP02", ["WP01"], graph)
|
|
215
|
+
>>> print(is_valid) # True
|
|
216
|
+
|
|
217
|
+
>>> is_valid, errors = validate_dependencies("WP02", ["WP99"], graph)
|
|
218
|
+
>>> print(is_valid) # False
|
|
219
|
+
>>> print(errors) # ["Dependency WP99 not found in graph"]
|
|
220
|
+
"""
|
|
221
|
+
errors = []
|
|
222
|
+
wp_pattern = re.compile(r"^WP\d{2}$")
|
|
223
|
+
|
|
224
|
+
# Validate each dependency
|
|
225
|
+
for dep in declared_deps:
|
|
226
|
+
# Check format
|
|
227
|
+
if not wp_pattern.match(dep):
|
|
228
|
+
errors.append(f"Invalid WP ID format: {dep} (must be WP## like WP01)")
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
# Check self-dependency
|
|
232
|
+
if dep == wp_id:
|
|
233
|
+
errors.append(f"Cannot depend on self: {wp_id} → {wp_id}")
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Check dependency exists in graph
|
|
237
|
+
if dep not in graph:
|
|
238
|
+
errors.append(f"Dependency {dep} not found in graph")
|
|
239
|
+
|
|
240
|
+
# Check for circular dependencies
|
|
241
|
+
# Build temporary graph with this WP's dependencies to check for cycles
|
|
242
|
+
test_graph = graph.copy()
|
|
243
|
+
test_graph[wp_id] = declared_deps
|
|
244
|
+
|
|
245
|
+
cycles = detect_cycles(test_graph)
|
|
246
|
+
if cycles:
|
|
247
|
+
for cycle in cycles:
|
|
248
|
+
if wp_id in cycle:
|
|
249
|
+
errors.append(f"Circular dependency detected: {' → '.join(cycle)}")
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
is_valid = len(errors) == 0
|
|
253
|
+
return is_valid, errors
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def topological_sort(graph: dict[str, list[str]]) -> list[str]:
|
|
257
|
+
"""Return nodes in topological order (dependencies before dependents).
|
|
258
|
+
|
|
259
|
+
Uses Kahn's algorithm:
|
|
260
|
+
1. Find all nodes with no incoming edges (no dependencies)
|
|
261
|
+
2. Remove them from graph, add to result
|
|
262
|
+
3. Repeat until graph is empty
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
graph: Adjacency list where graph[node] = [dependencies]
|
|
266
|
+
Note: This is REVERSE of typical adjacency (edges point to deps)
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
List of node IDs in topological order
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
ValueError: If graph contains a cycle (use detect_cycles() first)
|
|
273
|
+
|
|
274
|
+
Example:
|
|
275
|
+
>>> graph = {"WP01": [], "WP02": ["WP01"], "WP03": ["WP01", "WP02"]}
|
|
276
|
+
>>> topological_sort(graph)
|
|
277
|
+
['WP01', 'WP02', 'WP03']
|
|
278
|
+
"""
|
|
279
|
+
# Build in-degree map and reverse adjacency
|
|
280
|
+
in_degree: dict[str, int] = {node: 0 for node in graph}
|
|
281
|
+
reverse_adj: dict[str, list[str]] = {node: [] for node in graph}
|
|
282
|
+
|
|
283
|
+
for node, deps in graph.items():
|
|
284
|
+
in_degree[node] = len(deps)
|
|
285
|
+
for dep in deps:
|
|
286
|
+
if dep in reverse_adj:
|
|
287
|
+
reverse_adj[dep].append(node)
|
|
288
|
+
|
|
289
|
+
# Start with nodes that have no dependencies
|
|
290
|
+
queue = [node for node, degree in in_degree.items() if degree == 0]
|
|
291
|
+
queue.sort() # Stable ordering for determinism
|
|
292
|
+
|
|
293
|
+
result = []
|
|
294
|
+
while queue:
|
|
295
|
+
node = queue.pop(0)
|
|
296
|
+
result.append(node)
|
|
297
|
+
|
|
298
|
+
# "Remove" this node by decrementing in-degree of dependents
|
|
299
|
+
for dependent in sorted(reverse_adj.get(node, [])):
|
|
300
|
+
in_degree[dependent] -= 1
|
|
301
|
+
if in_degree[dependent] == 0:
|
|
302
|
+
queue.append(dependent)
|
|
303
|
+
queue.sort() # Maintain sorted order
|
|
304
|
+
|
|
305
|
+
if len(result) != len(graph):
|
|
306
|
+
raise ValueError("Graph contains a cycle - cannot topologically sort")
|
|
307
|
+
|
|
308
|
+
return result
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def get_dependents(wp_id: str, graph: dict[str, list[str]]) -> list[str]:
|
|
312
|
+
"""Get list of WPs that depend on this WP (inverse graph query).
|
|
313
|
+
|
|
314
|
+
Builds inverse graph and returns direct dependents only (not transitive).
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
wp_id: Work package ID to query
|
|
318
|
+
graph: Dependency graph (adjacency list)
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
List of WP IDs that directly depend on wp_id
|
|
322
|
+
Returns empty list if no dependents or WP not in graph
|
|
323
|
+
|
|
324
|
+
Examples:
|
|
325
|
+
>>> graph = {"WP01": [], "WP02": ["WP01"], "WP03": ["WP01"]}
|
|
326
|
+
>>> deps = get_dependents("WP01", graph)
|
|
327
|
+
>>> print(sorted(deps)) # ["WP02", "WP03"]
|
|
328
|
+
|
|
329
|
+
>>> deps = get_dependents("WP02", graph)
|
|
330
|
+
>>> print(deps) # []
|
|
331
|
+
"""
|
|
332
|
+
# Build inverse graph: wp -> list of wps that depend on it
|
|
333
|
+
inverse_graph: dict[str, list[str]] = {wp: [] for wp in graph}
|
|
334
|
+
|
|
335
|
+
for wp, dependencies in graph.items():
|
|
336
|
+
for dependency in dependencies:
|
|
337
|
+
if dependency not in inverse_graph:
|
|
338
|
+
inverse_graph[dependency] = []
|
|
339
|
+
inverse_graph[dependency].append(wp)
|
|
340
|
+
|
|
341
|
+
return inverse_graph.get(wp_id, [])
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
__all__ = [
|
|
345
|
+
"build_dependency_graph",
|
|
346
|
+
"detect_cycles",
|
|
347
|
+
"get_dependents",
|
|
348
|
+
"parse_wp_dependencies",
|
|
349
|
+
"topological_sort",
|
|
350
|
+
"validate_dependencies",
|
|
351
|
+
]
|