monoco-toolkit 0.3.3__py3-none-any.whl → 0.3.6__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.
@@ -267,10 +267,6 @@ def start(
267
267
  @app.command("submit")
268
268
  def submit(
269
269
  issue_id: str = typer.Argument(..., help="Issue ID to submit"),
270
- prune: bool = typer.Option(
271
- False, "--prune", help="Delete branch/worktree after submit"
272
- ),
273
- force: bool = typer.Option(False, "--force", help="Force delete branch/worktree"),
274
270
  root: Optional[str] = typer.Option(
275
271
  None, "--root", help="Override issues root directory"
276
272
  ),
@@ -292,22 +288,11 @@ def submit(
292
288
  except Exception as e:
293
289
  report_status = f"failed: {e}"
294
290
 
295
- pruned_resources = []
296
- if prune:
297
- try:
298
- pruned_resources = core.prune_issue_resources(
299
- issues_root, issue_id, force, project_root
300
- )
301
- except Exception as e:
302
- OutputManager.error(f"Prune Error: {e}")
303
- raise typer.Exit(code=1)
304
-
305
291
  OutputManager.print(
306
292
  {
307
293
  "issue": issue,
308
294
  "status": "submitted",
309
295
  "report": report_status,
310
- "pruned": pruned_resources,
311
296
  }
312
297
  )
313
298
 
@@ -221,6 +221,10 @@ def create_issue_file(
221
221
  if not find_issue_path(issues_root, rel_id):
222
222
  raise ValueError(f"Related issue {rel_id} not found.")
223
223
 
224
+ # Auto-assign default parent for non-epic types if not provided
225
+ if issue_type != IssueType.EPIC and not parent:
226
+ parent = "EPIC-0000"
227
+
224
228
  issue_id = find_next_id(issue_type, issues_root)
225
229
  base_type_dir = get_issue_dir(issue_type, issues_root)
226
230
  target_dir = base_type_dir / status
@@ -169,35 +169,6 @@ class StateMachine:
169
169
  if meta.stage is None:
170
170
  meta.stage = "draft"
171
171
 
172
- def validate_transition(
173
- self,
174
- from_status: str,
175
- from_stage: Optional[str],
176
- to_status: str,
177
- to_stage: Optional[str],
178
- solution: Optional[str] = None,
179
- ) -> None:
180
- """
181
- Validate if a transition is allowed. Raises ValueError if not.
182
- """
183
- if from_status == to_status and from_stage == to_stage:
184
- return # No change is always allowed (unless we want to enforce specific updates)
185
-
186
- transition = self.find_transition(
187
- from_status, from_stage, to_status, to_stage, solution
188
- )
189
-
190
- if not transition:
191
- raise ValueError(
192
- f"Lifecycle Policy: Transition from {from_status}({from_stage if from_stage else 'None'}) "
193
- f"to {to_status}({to_stage if to_stage else 'None'}) is not defined."
194
- )
195
-
196
- if transition.required_solution and solution != transition.required_solution:
197
- raise ValueError(
198
- f"Lifecycle Policy: Transition '{transition.label}' requires solution '{transition.required_solution}'."
199
- )
200
-
201
172
  def enforce_policy(self, meta: IssueMetadata) -> None:
202
173
  """
203
174
  Apply consistency rules to IssueMetadata.
@@ -4,7 +4,7 @@ from rich.console import Console
4
4
  from rich.table import Table
5
5
  import typer
6
6
  import re
7
- from monoco.core import git
7
+ from monoco.core.config import get_config
8
8
  from . import core
9
9
  from .validator import IssueValidator
10
10
  from monoco.core.lsp import Diagnostic, DiagnosticSeverity, Range, Position
@@ -12,36 +12,8 @@ from monoco.core.lsp import Diagnostic, DiagnosticSeverity, Range, Position
12
12
  console = Console()
13
13
 
14
14
 
15
- def check_environment_policy(project_root: Path):
16
- """
17
- Guardrail: Prevent direct modifications on protected branches (main/master).
18
- """
19
- # Only enforce if it is a git repo
20
- try:
21
- if not git.is_git_repo(project_root):
22
- return
23
-
24
- current_branch = git.get_current_branch(project_root)
25
- # Standard protected branches
26
- if current_branch in ["main", "master", "production"]:
27
- # Check if dirty (uncommitted changes)
28
- changed_files = git.get_git_status(project_root)
29
- if changed_files:
30
- console.print("\n[bold red]🛑 Environment Policy Violation[/bold red]")
31
- console.print(
32
- f"You are modifying code directly on protected branch: [bold cyan]{current_branch}[/bold cyan]"
33
- )
34
- console.print(f"Found {len(changed_files)} uncommitted changes.")
35
- console.print(
36
- "[yellow]Action Required:[/yellow] Please stash your changes and switch to a feature branch."
37
- )
38
- console.print(" > git stash")
39
- console.print(" > monoco issue start <ID> --branch")
40
- console.print(" > git stash pop")
41
- raise typer.Exit(code=1)
42
- except Exception:
43
- # Fail safe: Do not block linting if git check fails unexpectedly
44
- pass
15
+ # Removed check_environment_policy as per project philosophy:
16
+ # Toolkit should not interfere with Git operations.
45
17
 
46
18
 
47
19
  def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnostic]:
@@ -77,7 +49,7 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
77
49
  all_issue_ids.add(local_id)
78
50
  all_issue_ids.add(full_id)
79
51
 
80
- project_issues.append((f, meta))
52
+ project_issues.append((f, meta, project_name))
81
53
  except Exception as e:
82
54
  # Report parsing failure as diagnostic
83
55
  d = Diagnostic(
@@ -93,8 +65,6 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
93
65
  diagnostics.append(d)
94
66
  return project_issues
95
67
 
96
- from monoco.core.config import get_config
97
-
98
68
  conf = get_config(str(issues_root.parent))
99
69
 
100
70
  # Identify local project name
@@ -110,6 +80,17 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
110
80
  ).exists():
111
81
  workspace_root = parent
112
82
 
83
+ # Identify local project name
84
+ local_project_name = "local"
85
+ if conf and conf.project and conf.project.name:
86
+ local_project_name = conf.project.name.lower()
87
+
88
+ workspace_root_name = local_project_name
89
+ if workspace_root != issues_root.parent:
90
+ root_conf = get_config(str(workspace_root))
91
+ if root_conf and root_conf.project and root_conf.project.name:
92
+ workspace_root_name = root_conf.project.name.lower()
93
+
113
94
  # Collect from local issues_root
114
95
  all_issues.extend(collect_project_issues(issues_root, local_project_name))
115
96
 
@@ -140,11 +121,17 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
140
121
  pass
141
122
 
142
123
  # 2. Validation Phase
143
- for path, meta in all_issues:
124
+ for path, meta, project_name in all_issues:
144
125
  content = path.read_text() # Re-read content for validation
145
126
 
146
127
  # A. Run Core Validator
147
- file_diagnostics = validator.validate(meta, content, all_issue_ids)
128
+ file_diagnostics = validator.validate(
129
+ meta,
130
+ content,
131
+ all_issue_ids,
132
+ current_project=project_name,
133
+ workspace_root=workspace_root_name,
134
+ )
148
135
 
149
136
  # Add context to diagnostics (Path)
150
137
  for d in file_diagnostics:
@@ -172,9 +159,8 @@ def run_lint(
172
159
  format: Output format (table, json)
173
160
  file_paths: Optional list of paths to files to validate (LSP/Pre-commit mode)
174
161
  """
175
- # 0. Environment Policy Check (Guardrail)
176
- # We assume issues_root.parent is the project root or close enough for git context
177
- check_environment_policy(issues_root.parent)
162
+ # No environment policy check here.
163
+ # Toolkit should remain focused on Issue integrity.
178
164
 
179
165
  diagnostics = []
180
166
 
@@ -215,7 +201,16 @@ def run_lint(
215
201
  continue
216
202
 
217
203
  content = file.read_text()
218
- file_diagnostics = validator.validate(meta, content, all_issue_ids)
204
+
205
+ # Try to resolve current project name for context
206
+ current_project_name = "local"
207
+ conf = get_config(str(issues_root.parent))
208
+ if conf and conf.project and conf.project.name:
209
+ current_project_name = conf.project.name.lower()
210
+
211
+ file_diagnostics = validator.validate(
212
+ meta, content, all_issue_ids, current_project=current_project_name
213
+ )
219
214
 
220
215
  # Add context
221
216
  for d in file_diagnostics:
@@ -1,6 +1,6 @@
1
1
  from enum import Enum
2
2
  from typing import List, Optional, Any, Dict
3
- from pydantic import BaseModel, Field, model_validator
3
+ from pydantic import BaseModel, Field, model_validator, ConfigDict
4
4
  from datetime import datetime
5
5
  import hashlib
6
6
  import secrets
@@ -105,13 +105,13 @@ class IssueAction(BaseModel):
105
105
 
106
106
 
107
107
  class IssueMetadata(BaseModel):
108
- model_config = {"extra": "allow"}
108
+ model_config = ConfigDict(extra="allow", validate_assignment=True)
109
109
 
110
110
  id: str
111
111
  uid: Optional[str] = None # Global unique identifier for cross-project identity
112
- type: str
113
- status: str = "open"
114
- stage: Optional[str] = None
112
+ type: IssueType
113
+ status: IssueStatus = IssueStatus.OPEN
114
+ stage: Optional[IssueStage] = None
115
115
  title: str
116
116
 
117
117
  # Time Anchors
@@ -122,7 +122,7 @@ class IssueMetadata(BaseModel):
122
122
 
123
123
  parent: Optional[str] = None
124
124
  sprint: Optional[str] = None
125
- solution: Optional[str] = None
125
+ solution: Optional[IssueSolution] = None
126
126
  isolation: Optional[IssueIsolation] = None
127
127
  dependencies: List[str] = []
128
128
  related: List[str] = []
@@ -161,26 +161,56 @@ class IssueMetadata(BaseModel):
161
161
  # Normalize type and status to lowercase for compatibility
162
162
  if "type" in v and isinstance(v["type"], str):
163
163
  v["type"] = v["type"].lower()
164
+ try:
165
+ v["type"] = IssueType(v["type"])
166
+ except ValueError:
167
+ pass
168
+
164
169
  if "status" in v and isinstance(v["status"], str):
165
170
  v["status"] = v["status"].lower()
171
+ try:
172
+ v["status"] = IssueStatus(v["status"])
173
+ except ValueError:
174
+ pass
175
+
166
176
  if "solution" in v and isinstance(v["solution"], str):
167
177
  v["solution"] = v["solution"].lower()
178
+ try:
179
+ v["solution"] = IssueSolution(v["solution"])
180
+ except ValueError:
181
+ pass
182
+
168
183
  # Stage normalization
169
184
  if "stage" in v and isinstance(v["stage"], str):
170
185
  v["stage"] = v["stage"].lower()
171
186
  if v["stage"] == "todo":
172
187
  v["stage"] = "draft"
188
+ try:
189
+ v["stage"] = IssueStage(v["stage"])
190
+ except ValueError:
191
+ pass
173
192
  return v
174
193
 
175
194
  @model_validator(mode="after")
176
195
  def validate_lifecycle(self) -> "IssueMetadata":
177
- # Logic Definition:
178
- # status: backlog -> stage: freezed
179
- # status: closed -> stage: done
180
- # status: open -> stage: draft | doing | review | done (default draft)
181
-
182
- # NOTE: We do NOT auto-correct state here anymore to allow Linter to detect inconsistencies.
183
- # Auto-correction should be applied explicitly by 'create' or 'update' commands via core logic.
196
+ # 1. Solution Consistency: Closed issues MUST have a solution
197
+ if self.status == IssueStatus.CLOSED and not self.solution:
198
+ raise ValueError(f"Issue '{self.id}' is closed but 'solution' is missing.")
199
+
200
+ # 2. Hierarchy Consistency: non-epic types MUST have a parent (except specific root seeds)
201
+ if self.type != IssueType.EPIC and not self.parent:
202
+ # We allow exceptions for very specific bootstrap cases if needed, but currently enforce it.
203
+ if self.id not in ["FEAT-BOOTSTRAP"]: # Example exception
204
+ raise ValueError(
205
+ f"Issue '{self.id}' of type '{self.type}' must have a 'parent' reference."
206
+ )
207
+
208
+ # 3. State/Stage Consistency (Warnings or Errors)
209
+ # Note: In Monoco, status: closed is tightly coupled with stage: done
210
+ if self.status == IssueStatus.CLOSED and self.stage != IssueStage.DONE:
211
+ # We could auto-fix here, but let's be strict for Validation purposes
212
+ # raise ValueError(f"Issue '{self.id}' is closed but stage is '{self.stage}' (expected 'done').")
213
+ pass
184
214
 
185
215
  return self
186
216
 
@@ -0,0 +1,177 @@
1
+ """
2
+ Reference Resolution Engine for Multi-Project Environments.
3
+
4
+ This module implements a priority-based resolution strategy for Issue ID references
5
+ in multi-project/workspace environments.
6
+
7
+ Resolution Priority:
8
+ 1. Explicit Namespace (namespace::ID) - Highest priority
9
+ 2. Proximity Rule (Current Project Context)
10
+ 3. Root Fallback (Workspace Root)
11
+ """
12
+
13
+ from typing import Optional, Set, Dict
14
+ from dataclasses import dataclass
15
+
16
+
17
+ @dataclass
18
+ class ResolutionContext:
19
+ """Context information for reference resolution."""
20
+
21
+ current_project: str
22
+ """Name of the current project (e.g., 'toolkit', 'typedown')."""
23
+
24
+ workspace_root: Optional[str] = None
25
+ """Name of the workspace root project (e.g., 'monoco')."""
26
+
27
+ available_ids: Set[str] = None
28
+ """Set of all available Issue IDs (both local and namespaced)."""
29
+
30
+ def __post_init__(self):
31
+ if self.available_ids is None:
32
+ self.available_ids = set()
33
+
34
+
35
+ class ReferenceResolver:
36
+ """
37
+ Resolves Issue ID references with multi-project awareness.
38
+
39
+ Supports:
40
+ - Explicit namespace syntax: `namespace::ID`
41
+ - Proximity-based resolution
42
+ - Root fallback for global issues
43
+ """
44
+
45
+ def __init__(self, context: ResolutionContext):
46
+ self.context = context
47
+
48
+ # Build index for fast lookup
49
+ self._local_ids: Set[str] = set()
50
+ self._namespaced_ids: Dict[str, Set[str]] = {}
51
+
52
+ for issue_id in context.available_ids:
53
+ if "::" in issue_id:
54
+ # Namespaced ID
55
+ namespace, local_id = issue_id.split("::", 1)
56
+ if namespace not in self._namespaced_ids:
57
+ self._namespaced_ids[namespace] = set()
58
+ self._namespaced_ids[namespace].add(local_id)
59
+ else:
60
+ # Local ID
61
+ self._local_ids.add(issue_id)
62
+
63
+ def resolve(self, reference: str) -> Optional[str]:
64
+ """
65
+ Resolve an Issue ID reference to its canonical form.
66
+
67
+ Args:
68
+ reference: The reference to resolve (e.g., "FEAT-0001" or "toolkit::FEAT-0001")
69
+
70
+ Returns:
71
+ The canonical ID if found, None otherwise.
72
+ For namespaced IDs, returns the full form (e.g., "toolkit::FEAT-0001").
73
+ For local IDs, returns the short form (e.g., "FEAT-0001").
74
+
75
+ Resolution Strategy:
76
+ 1. If reference contains "::", treat as explicit namespace
77
+ 2. Otherwise, apply proximity rule:
78
+ a. Check current project context
79
+ b. Check workspace root (if different from current)
80
+ c. Check if exists as local ID
81
+ """
82
+ # Strategy 1: Explicit Namespace
83
+ if "::" in reference:
84
+ return self._resolve_explicit(reference)
85
+
86
+ # Strategy 2: Proximity Rule
87
+ return self._resolve_proximity(reference)
88
+
89
+ def _resolve_explicit(self, reference: str) -> Optional[str]:
90
+ """Resolve explicitly namespaced reference."""
91
+ if reference in self.context.available_ids:
92
+ return reference
93
+ return None
94
+
95
+ def _resolve_proximity(self, reference: str) -> Optional[str]:
96
+ """
97
+ Resolve reference using proximity rule.
98
+
99
+ Priority:
100
+ 1. Current project namespace
101
+ 2. Workspace root namespace
102
+ 3. Local (unnamespaced) ID
103
+ """
104
+ # Priority 1: Current project
105
+ current_namespaced = f"{self.context.current_project}::{reference}"
106
+ if current_namespaced in self.context.available_ids:
107
+ return current_namespaced
108
+
109
+ # Priority 2: Workspace root (if different from current)
110
+ if (
111
+ self.context.workspace_root
112
+ and self.context.workspace_root != self.context.current_project
113
+ ):
114
+ root_namespaced = f"{self.context.workspace_root}::{reference}"
115
+ if root_namespaced in self.context.available_ids:
116
+ return root_namespaced
117
+
118
+ # Priority 3: Local ID
119
+ if reference in self._local_ids:
120
+ return reference
121
+
122
+ return None
123
+
124
+ def is_valid_reference(self, reference: str) -> bool:
125
+ """Check if a reference can be resolved."""
126
+ return self.resolve(reference) is not None
127
+
128
+ def get_resolution_chain(self, reference: str) -> list[str]:
129
+ """
130
+ Get the resolution chain for debugging purposes.
131
+
132
+ Returns a list of candidate IDs that were checked in order.
133
+ """
134
+ chain = []
135
+
136
+ if "::" in reference:
137
+ chain.append(reference)
138
+ else:
139
+ # Proximity chain
140
+ chain.append(f"{self.context.current_project}::{reference}")
141
+
142
+ if (
143
+ self.context.workspace_root
144
+ and self.context.workspace_root != self.context.current_project
145
+ ):
146
+ chain.append(f"{self.context.workspace_root}::{reference}")
147
+
148
+ chain.append(reference)
149
+
150
+ return chain
151
+
152
+
153
+ def resolve_reference(
154
+ reference: str,
155
+ context_project: str,
156
+ available_ids: Set[str],
157
+ workspace_root: Optional[str] = None,
158
+ ) -> Optional[str]:
159
+ """
160
+ Convenience function for resolving a single reference.
161
+
162
+ Args:
163
+ reference: The Issue ID to resolve
164
+ context_project: Current project name
165
+ available_ids: Set of all available Issue IDs
166
+ workspace_root: Optional workspace root project name
167
+
168
+ Returns:
169
+ Resolved canonical ID or None if not found
170
+ """
171
+ context = ResolutionContext(
172
+ current_project=context_project,
173
+ workspace_root=workspace_root,
174
+ available_ids=available_ids,
175
+ )
176
+ resolver = ReferenceResolver(context)
177
+ return resolver.resolve(reference)
@@ -11,10 +11,12 @@ System for managing tasks using `monoco issue`.
11
11
  - **Sync Context**: `monoco issue sync-files [id]` (Update file tracking)
12
12
  - **Structure**: `Issues/{CapitalizedPluralType}/{lowercase_status}/` (e.g. `Issues/Features/open/`). Do not deviate.
13
13
  - **Rules**:
14
- 1. **Heading**: Must have `## {ID}: {Title}` (matches metadata).
15
- 2. **Checkboxes**: Min 2 using `- [ ]`, `- [x]`, `- [-]`, `- [/]`.
16
- 3. **Review**: `## Review Comments` section required for Review/Done stages.
17
- 4. **Environment Policies**:
14
+ 1. **Issue First**: You MUST create an Issue (`monoco issue create`) before starting any work (research, design, or drafting).
15
+ 2. **Heading**: Must have `## {ID}: {Title}` (matches metadata).
16
+ 3. **Checkboxes**: Min 2 using `- [ ]`, `- [x]`, `- [-]`, `- [/]`.
17
+ 4. **Review**: `## Review Comments` section required for Review/Done stages.
18
+ 5. **Environment Policies**:
18
19
  - Must use `monoco issue start --branch`.
19
20
  - 🛑 **NO** direct coding on `main`/`master` (Linter will fail).
21
+ - **Prune Timing**: ONLY prune environment (branch/worktree) during `monoco issue close --prune`. NEVER prune at `submit` stage.
20
22
  - Must update `files` field after coding (via `sync-files` or manual).
@@ -11,10 +11,12 @@
11
11
  - **上下文同步**: `monoco issue sync-files [id]` (更新文件追踪)
12
12
  - **结构**: `Issues/{CapitalizedPluralType}/{lowercase_status}/` (如 `Issues/Features/open/`)。
13
13
  - **强制规则**:
14
- 1. **标题**: 必须包含 `## {ID}: {Title}` 标题(与 Front Matter 一致)。
15
- 2. **内容**: 至少 2 Checkbox,使用 `- [ ]`, `- [x]`, `- [-]`, `- [/]`。
16
- 3. **评审**: `review`/`done` 阶段必须包含 `## Review Comments` 章节且内容不为空。
17
- 4. **环境策略**:
14
+ 1. **先有 Issue**: 在进行任何调研、设计或 Draft 之前,必须先使用 `monoco issue create` 创建 Issue。
15
+ 2. **标题**: 必须包含 `## {ID}: {Title}` 标题(与 Front Matter 一致)。
16
+ 3. **内容**: 至少 2 Checkbox,使用 `- [ ]`, `- [x]`, `- [-]`, `- [/]`。
17
+ 4. **评审**: `review`/`done` 阶段必须包含 `## Review Comments` 章节且内容不为空。
18
+ 5. **环境策略**:
18
19
  - 必须使用 `monoco issue start --branch` 创建 Feature 分支。
19
20
  - 🛑 **禁止**直接在 `main`/`master` 分支修改代码 (Linter 会报错)。
21
+ - **清理时机**: 环境清理仅应在 `close` 时执行。**禁止**在 `submit` 阶段清理环境。
20
22
  - 修改代码后**必须**更新 `files` 字段(通过 `sync-files` 或手动)。
@@ -0,0 +1,103 @@
1
+ from monoco.features.issue.validator import IssueValidator
2
+ from monoco.features.issue.models import IssueMetadata
3
+ from datetime import datetime
4
+
5
+
6
+ def test_validator_namespaced_reference_in_body():
7
+ validator = IssueValidator()
8
+ meta = IssueMetadata(
9
+ id="FEAT-0001",
10
+ uid="123456",
11
+ type="feature",
12
+ status="open",
13
+ stage="draft",
14
+ title="Test Issue",
15
+ created_at=datetime.now(),
16
+ opened_at=datetime.now(),
17
+ updated_at=datetime.now(),
18
+ domains=["intelligence"],
19
+ parent="EPIC-0000",
20
+ )
21
+
22
+ # Context: toolkit project, monoco workspace
23
+ # Available IDs include namespaced versions
24
+ all_ids = {"monoco::EPIC-0001", "toolkit::FEAT-0002"}
25
+
26
+ # 1. Test namespaced reference in body
27
+ content = """---
28
+ id: FEAT-0001
29
+ title: Test Issue
30
+ ---
31
+
32
+ ## FEAT-0001: Test Issue
33
+
34
+ This depends on monoco::EPIC-0001 and toolkit::FEAT-0002.
35
+ Broken one: other::FIX-9999.
36
+ """
37
+
38
+ diagnostics = validator.validate(
39
+ meta, content, all_ids, current_project="toolkit", workspace_root="monoco"
40
+ )
41
+
42
+ # Should have 1 warning for other::FIX-9999
43
+ warnings = [d for d in diagnostics if "Broken Reference" in d.message]
44
+ assert len(warnings) == 1
45
+ assert "other::FIX-9999" in warnings[0].message
46
+
47
+ # 2. Test proximity resolution in body
48
+ content_proximity = """---
49
+ id: FEAT-0001
50
+ title: Test Issue
51
+ ---
52
+
53
+ ## FEAT-0001: Test Issue
54
+
55
+ Referencing EPIC-0001 (should resolve to monoco::EPIC-0001 via root fallback).
56
+ Referencing FEAT-0002 (should resolve to toolkit::FEAT-0002 via proximity).
57
+ """
58
+
59
+ diagnostics = validator.validate(
60
+ meta,
61
+ content_proximity,
62
+ all_ids,
63
+ current_project="toolkit",
64
+ workspace_root="monoco",
65
+ )
66
+
67
+ warnings = [d for d in diagnostics if "Broken Reference" in d.message]
68
+ assert len(warnings) == 0 # Both should be resolved
69
+
70
+
71
+ def test_validator_parent_resolution():
72
+ validator = IssueValidator()
73
+ # Epic in toolkit
74
+ meta = IssueMetadata(
75
+ id="EPIC-0100",
76
+ uid="111",
77
+ type="epic",
78
+ status="open",
79
+ stage="draft",
80
+ title="Sub Epic",
81
+ parent="EPIC-0000", # Root Epic in workspace root
82
+ created_at=datetime.now(),
83
+ opened_at=datetime.now(),
84
+ updated_at=datetime.now(),
85
+ domains=["intelligence"],
86
+ )
87
+
88
+ all_ids = {"monoco::EPIC-0000", "toolkit::EPIC-0100"}
89
+
90
+ content = """---
91
+ id: EPIC-0100
92
+ parent: EPIC-0000
93
+ ---
94
+ """
95
+
96
+ # Context: current=toolkit, root=monoco
97
+ diagnostics = validator.validate(
98
+ meta, content, all_ids, current_project="toolkit", workspace_root="monoco"
99
+ )
100
+
101
+ # Should be valid via root fallback
102
+ errors = [d for d in diagnostics if d.severity == 1] # DiagnosticSeverity.Error
103
+ assert len(errors) == 0