monoco-toolkit 0.3.2__py3-none-any.whl → 0.3.5__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.
Files changed (36) hide show
  1. monoco/core/config.py +35 -0
  2. monoco/core/integrations.py +0 -6
  3. monoco/core/sync.py +6 -19
  4. monoco/features/issue/commands.py +24 -16
  5. monoco/features/issue/core.py +90 -39
  6. monoco/features/issue/domain/models.py +1 -0
  7. monoco/features/issue/domain_commands.py +47 -0
  8. monoco/features/issue/domain_service.py +69 -0
  9. monoco/features/issue/linter.py +153 -50
  10. monoco/features/issue/resolver.py +177 -0
  11. monoco/features/issue/resources/en/AGENTS.md +6 -4
  12. monoco/features/issue/resources/zh/AGENTS.md +6 -4
  13. monoco/features/issue/test_priority_integration.py +102 -0
  14. monoco/features/issue/test_resolver.py +83 -0
  15. monoco/features/issue/validator.py +97 -21
  16. monoco/features/scheduler/__init__.py +19 -0
  17. monoco/features/scheduler/cli.py +285 -0
  18. monoco/features/scheduler/config.py +68 -0
  19. monoco/features/scheduler/defaults.py +54 -0
  20. monoco/features/scheduler/engines.py +149 -0
  21. monoco/features/scheduler/manager.py +49 -0
  22. monoco/features/scheduler/models.py +24 -0
  23. monoco/features/scheduler/reliability.py +106 -0
  24. monoco/features/scheduler/session.py +87 -0
  25. monoco/features/scheduler/worker.py +133 -0
  26. monoco/main.py +5 -0
  27. {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.5.dist-info}/METADATA +37 -46
  28. {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.5.dist-info}/RECORD +31 -21
  29. monoco/core/agent/__init__.py +0 -3
  30. monoco/core/agent/action.py +0 -168
  31. monoco/core/agent/adapters.py +0 -133
  32. monoco/core/agent/protocol.py +0 -32
  33. monoco/core/agent/state.py +0 -106
  34. {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.5.dist-info}/WHEEL +0 -0
  35. {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.5.dist-info}/entry_points.txt +0 -0
  36. {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.5.dist-info}/licenses/LICENSE +0 -0
@@ -4,44 +4,16 @@ 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
- from monoco.core.lsp import Diagnostic, DiagnosticSeverity
10
+ from monoco.core.lsp import Diagnostic, DiagnosticSeverity, Range, Position
11
11
 
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]:
@@ -68,19 +40,31 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
68
40
  files.extend(status_dir.rglob("*.md"))
69
41
 
70
42
  for f in files:
71
- meta = core.parse_issue(f)
72
- if meta:
73
- local_id = meta.id
74
- full_id = f"{project_name}::{local_id}"
75
-
76
- all_issue_ids.add(local_id)
77
- all_issue_ids.add(full_id)
78
-
79
- project_issues.append((f, meta))
43
+ try:
44
+ meta = core.parse_issue(f, raise_error=True)
45
+ if meta:
46
+ local_id = meta.id
47
+ full_id = f"{project_name}::{local_id}"
48
+
49
+ all_issue_ids.add(local_id)
50
+ all_issue_ids.add(full_id)
51
+
52
+ project_issues.append((f, meta, project_name))
53
+ except Exception as e:
54
+ # Report parsing failure as diagnostic
55
+ d = Diagnostic(
56
+ range=Range(
57
+ start=Position(line=0, character=0),
58
+ end=Position(line=0, character=0),
59
+ ),
60
+ message=f"Schema Error: {str(e)}",
61
+ severity=DiagnosticSeverity.Error,
62
+ source="System",
63
+ )
64
+ d.data = {"path": f}
65
+ diagnostics.append(d)
80
66
  return project_issues
81
67
 
82
- from monoco.core.config import get_config
83
-
84
68
  conf = get_config(str(issues_root.parent))
85
69
 
86
70
  # Identify local project name
@@ -96,6 +80,17 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
96
80
  ).exists():
97
81
  workspace_root = parent
98
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
+
99
94
  # Collect from local issues_root
100
95
  all_issues.extend(collect_project_issues(issues_root, local_project_name))
101
96
 
@@ -126,11 +121,17 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
126
121
  pass
127
122
 
128
123
  # 2. Validation Phase
129
- for path, meta in all_issues:
124
+ for path, meta, project_name in all_issues:
130
125
  content = path.read_text() # Re-read content for validation
131
126
 
132
127
  # A. Run Core Validator
133
- 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
+ )
134
135
 
135
136
  # Add context to diagnostics (Path)
136
137
  for d in file_diagnostics:
@@ -158,9 +159,8 @@ def run_lint(
158
159
  format: Output format (table, json)
159
160
  file_paths: Optional list of paths to files to validate (LSP/Pre-commit mode)
160
161
  """
161
- # 0. Environment Policy Check (Guardrail)
162
- # We assume issues_root.parent is the project root or close enough for git context
163
- check_environment_policy(issues_root.parent)
162
+ # No environment policy check here.
163
+ # Toolkit should remain focused on Issue integrity.
164
164
 
165
165
  diagnostics = []
166
166
 
@@ -193,7 +193,7 @@ def run_lint(
193
193
 
194
194
  # Parse and validate file
195
195
  try:
196
- meta = core.parse_issue(file)
196
+ meta = core.parse_issue(file, raise_error=True)
197
197
  if not meta:
198
198
  console.print(
199
199
  f"[yellow]Warning:[/yellow] Failed to parse issue metadata from {file_path}. Skipping."
@@ -201,7 +201,16 @@ def run_lint(
201
201
  continue
202
202
 
203
203
  content = file.read_text()
204
- 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
+ )
205
214
 
206
215
  # Add context
207
216
  for d in file_diagnostics:
@@ -315,6 +324,36 @@ def run_lint(
315
324
  new_content = "\n".join(lines) + "\n"
316
325
  has_changes = True
317
326
 
327
+ if (
328
+ "Hierarchy Violation" in d.message
329
+ and "Epics must have a parent" in d.message
330
+ ):
331
+ try:
332
+ fm_match = re.search(
333
+ r"^---(.*?)---", new_content, re.DOTALL | re.MULTILINE
334
+ )
335
+ if fm_match:
336
+ import yaml
337
+
338
+ fm_text = fm_match.group(1)
339
+ data = yaml.safe_load(fm_text) or {}
340
+
341
+ # Default to EPIC-0000
342
+ data["parent"] = "EPIC-0000"
343
+
344
+ new_fm_text = yaml.dump(
345
+ data, sort_keys=False, allow_unicode=True
346
+ )
347
+ # Replace FM block
348
+ new_content = new_content.replace(
349
+ fm_match.group(1), "\n" + new_fm_text
350
+ )
351
+ has_changes = True
352
+ except Exception as ex:
353
+ console.print(
354
+ f"[red]Failed to fix parent hierarchy: {ex}[/red]"
355
+ )
356
+
318
357
  if "Tag Check: Missing required context tags" in d.message:
319
358
  # Extract missing tags from message
320
359
  # Message format: "Tag Check: Missing required context tags: #TAG1, #TAG2"
@@ -426,6 +465,70 @@ def run_lint(
426
465
  except Exception as e:
427
466
  console.print(f"[red]Failed to fix domains for {path.name}: {e}[/red]")
428
467
 
468
+ # Domain Alias Fix
469
+ try:
470
+ alias_fixes = [
471
+ d for d in current_file_diags if "Domain Alias:" in d.message
472
+ ]
473
+ if alias_fixes:
474
+ fm_match = re.search(
475
+ r"^---(.*?)---", new_content, re.DOTALL | re.MULTILINE
476
+ )
477
+ if fm_match:
478
+ import yaml
479
+
480
+ fm_text = fm_match.group(1)
481
+ data = yaml.safe_load(fm_text) or {}
482
+
483
+ domain_changed = False
484
+ if "domains" in data and isinstance(data["domains"], list):
485
+ domains = data["domains"]
486
+ for d in alias_fixes:
487
+ # Parse message: Domain Alias: 'alias' is an alias for 'canonical'.
488
+ m = re.search(
489
+ r"Domain Alias: '([^']+)' is an alias for '([^']+)'",
490
+ d.message,
491
+ )
492
+ if m:
493
+ old_d = m.group(1)
494
+ new_d = m.group(2)
495
+
496
+ if old_d in domains:
497
+ domains = [
498
+ new_d if x == old_d else x for x in domains
499
+ ]
500
+ domain_changed = True
501
+
502
+ if domain_changed:
503
+ data["domains"] = domains
504
+ new_fm_text = yaml.dump(
505
+ data, sort_keys=False, allow_unicode=True
506
+ )
507
+ new_content = new_content.replace(
508
+ fm_match.group(1), "\n" + new_fm_text
509
+ )
510
+ has_changes = True
511
+
512
+ # Write immediately if not handled by previous block?
513
+ # We are in standard flow where has_changes flag handles write at end of loop?
514
+ # Wait, the previous block (Missing domains) logic wrote internally ONLY if has_changes.
515
+ # AND it reset has_changes=False at start of try?
516
+ # Actually the previous block structure was separate try-except blocks.
517
+ # But here I am inserting AFTER the Missing Domains try-except (which was lines 390-442).
518
+ # But I need to write if I changed it.
519
+ path.write_text(new_content)
520
+ if not any(path == p for p in processed_paths):
521
+ fixed_count += 1
522
+ processed_paths.add(path)
523
+ console.print(
524
+ f"[dim]Fixed (Domain Alias): {path.name}[/dim]"
525
+ )
526
+
527
+ except Exception as e:
528
+ console.print(
529
+ f"[red]Failed to fix domain aliases for {path.name}: {e}[/red]"
530
+ )
531
+
429
532
  console.print(f"[green]Applied auto-fixes to {fixed_count} files.[/green]")
430
533
 
431
534
  # Re-run validation to verify
@@ -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,102 @@
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
+ )
20
+
21
+ # Context: toolkit project, monoco workspace
22
+ # Available IDs include namespaced versions
23
+ all_ids = {"monoco::EPIC-0001", "toolkit::FEAT-0002"}
24
+
25
+ # 1. Test namespaced reference in body
26
+ content = """---
27
+ id: FEAT-0001
28
+ title: Test Issue
29
+ ---
30
+
31
+ ## FEAT-0001: Test Issue
32
+
33
+ This depends on monoco::EPIC-0001 and toolkit::FEAT-0002.
34
+ Broken one: other::FIX-9999.
35
+ """
36
+
37
+ diagnostics = validator.validate(
38
+ meta, content, all_ids, current_project="toolkit", workspace_root="monoco"
39
+ )
40
+
41
+ # Should have 1 warning for other::FIX-9999
42
+ warnings = [d for d in diagnostics if "Broken Reference" in d.message]
43
+ assert len(warnings) == 1
44
+ assert "other::FIX-9999" in warnings[0].message
45
+
46
+ # 2. Test proximity resolution in body
47
+ content_proximity = """---
48
+ id: FEAT-0001
49
+ title: Test Issue
50
+ ---
51
+
52
+ ## FEAT-0001: Test Issue
53
+
54
+ Referencing EPIC-0001 (should resolve to monoco::EPIC-0001 via root fallback).
55
+ Referencing FEAT-0002 (should resolve to toolkit::FEAT-0002 via proximity).
56
+ """
57
+
58
+ diagnostics = validator.validate(
59
+ meta,
60
+ content_proximity,
61
+ all_ids,
62
+ current_project="toolkit",
63
+ workspace_root="monoco",
64
+ )
65
+
66
+ warnings = [d for d in diagnostics if "Broken Reference" in d.message]
67
+ assert len(warnings) == 0 # Both should be resolved
68
+
69
+
70
+ def test_validator_parent_resolution():
71
+ validator = IssueValidator()
72
+ # Epic in toolkit
73
+ meta = IssueMetadata(
74
+ id="EPIC-0100",
75
+ uid="111",
76
+ type="epic",
77
+ status="open",
78
+ stage="draft",
79
+ title="Sub Epic",
80
+ parent="EPIC-0000", # Root Epic in workspace root
81
+ created_at=datetime.now(),
82
+ opened_at=datetime.now(),
83
+ updated_at=datetime.now(),
84
+ domains=["intelligence"],
85
+ )
86
+
87
+ all_ids = {"monoco::EPIC-0000", "toolkit::EPIC-0100"}
88
+
89
+ content = """---
90
+ id: EPIC-0100
91
+ parent: EPIC-0000
92
+ ---
93
+ """
94
+
95
+ # Context: current=toolkit, root=monoco
96
+ diagnostics = validator.validate(
97
+ meta, content, all_ids, current_project="toolkit", workspace_root="monoco"
98
+ )
99
+
100
+ # Should be valid via root fallback
101
+ errors = [d for d in diagnostics if d.severity == 1] # DiagnosticSeverity.Error
102
+ assert len(errors) == 0
@@ -0,0 +1,83 @@
1
+ from monoco.features.issue.resolver import ReferenceResolver, ResolutionContext
2
+
3
+
4
+ def test_resolve_explicit_namespace():
5
+ context = ResolutionContext(
6
+ current_project="toolkit",
7
+ workspace_root="monoco",
8
+ available_ids={"toolkit::FEAT-0001", "monoco::FEAT-0001", "EPIC-0001"},
9
+ )
10
+ resolver = ReferenceResolver(context)
11
+
12
+ # Explicitly project reference
13
+ assert resolver.resolve("toolkit::FEAT-0001") == "toolkit::FEAT-0001"
14
+ assert resolver.resolve("monoco::FEAT-0001") == "monoco::FEAT-0001"
15
+
16
+ # Non-existent namespace
17
+ assert resolver.resolve("other::FEAT-0001") is None
18
+
19
+
20
+ def test_resolve_proximity_current_project():
21
+ context = ResolutionContext(
22
+ current_project="toolkit",
23
+ workspace_root="monoco",
24
+ available_ids={
25
+ "toolkit::FEAT-0001",
26
+ "monoco::FEAT-0001",
27
+ },
28
+ )
29
+ resolver = ReferenceResolver(context)
30
+
31
+ # Should prefer current project
32
+ assert resolver.resolve("FEAT-0001") == "toolkit::FEAT-0001"
33
+
34
+
35
+ def test_resolve_root_fallback():
36
+ context = ResolutionContext(
37
+ current_project="toolkit",
38
+ workspace_root="monoco",
39
+ available_ids={
40
+ "monoco::EPIC-0000",
41
+ "toolkit::FEAT-0001",
42
+ },
43
+ )
44
+ resolver = ReferenceResolver(context)
45
+
46
+ # Should fallback to root if not in current
47
+ assert resolver.resolve("EPIC-0000") == "monoco::EPIC-0000"
48
+
49
+
50
+ def test_resolve_local_ids():
51
+ context = ResolutionContext(
52
+ current_project="toolkit",
53
+ workspace_root="monoco",
54
+ available_ids={
55
+ "EPIC-9999",
56
+ },
57
+ )
58
+ resolver = ReferenceResolver(context)
59
+
60
+ # Should resolve plain local IDs
61
+ assert resolver.resolve("EPIC-9999") == "EPIC-9999"
62
+
63
+
64
+ def test_priority_order():
65
+ context = ResolutionContext(
66
+ current_project="toolkit",
67
+ workspace_root="monoco",
68
+ available_ids={"toolkit::FEAT-0001", "monoco::FEAT-0001", "FEAT-0001"},
69
+ )
70
+ resolver = ReferenceResolver(context)
71
+
72
+ # Order: toolkit::FEAT-0001 > monoco::FEAT-0001 > FEAT-0001
73
+ assert resolver.resolve("FEAT-0001") == "toolkit::FEAT-0001"
74
+
75
+ # If removed from toolkit context
76
+ context.available_ids.remove("toolkit::FEAT-0001")
77
+ resolver = ReferenceResolver(context)
78
+ assert resolver.resolve("FEAT-0001") == "monoco::FEAT-0001"
79
+
80
+ # If removed from root context
81
+ context.available_ids.remove("monoco::FEAT-0001")
82
+ resolver = ReferenceResolver(context)
83
+ assert resolver.resolve("FEAT-0001") == "FEAT-0001"