monoco-toolkit 0.3.2__py3-none-any.whl → 0.3.3__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.
- monoco/core/config.py +35 -0
- monoco/core/integrations.py +0 -6
- monoco/core/sync.py +6 -19
- monoco/features/issue/commands.py +24 -1
- monoco/features/issue/core.py +90 -39
- monoco/features/issue/domain/models.py +1 -0
- monoco/features/issue/domain_commands.py +47 -0
- monoco/features/issue/domain_service.py +69 -0
- monoco/features/issue/linter.py +119 -11
- monoco/features/issue/validator.py +47 -0
- monoco/features/scheduler/__init__.py +19 -0
- monoco/features/scheduler/cli.py +204 -0
- monoco/features/scheduler/config.py +32 -0
- monoco/features/scheduler/defaults.py +54 -0
- monoco/features/scheduler/manager.py +49 -0
- monoco/features/scheduler/models.py +24 -0
- monoco/features/scheduler/reliability.py +99 -0
- monoco/features/scheduler/session.py +87 -0
- monoco/features/scheduler/worker.py +129 -0
- monoco/main.py +4 -0
- {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.3.dist-info}/METADATA +1 -1
- {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.3.dist-info}/RECORD +25 -19
- monoco/core/agent/__init__.py +0 -3
- monoco/core/agent/action.py +0 -168
- monoco/core/agent/adapters.py +0 -133
- monoco/core/agent/protocol.py +0 -32
- monoco/core/agent/state.py +0 -106
- {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.3.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.3.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.2.dist-info → monoco_toolkit-0.3.3.dist-info}/licenses/LICENSE +0 -0
monoco/core/config.py
CHANGED
|
@@ -133,6 +133,40 @@ class IssueSchemaConfig(BaseModel):
|
|
|
133
133
|
return self
|
|
134
134
|
|
|
135
135
|
|
|
136
|
+
class DomainItem(BaseModel):
|
|
137
|
+
name: str = Field(..., description="Canonical domain name (e.g. backend.auth)")
|
|
138
|
+
description: Optional[str] = Field(None, description="Description of the domain")
|
|
139
|
+
aliases: List[str] = Field(default_factory=list, description="List of aliases")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class DomainConfig(BaseModel):
|
|
143
|
+
items: List[DomainItem] = Field(
|
|
144
|
+
default_factory=list, description="List of defined domains"
|
|
145
|
+
)
|
|
146
|
+
strict: bool = Field(
|
|
147
|
+
default=False, description="If True, only allow defined domains"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def merge(self, other: "DomainConfig") -> "DomainConfig":
|
|
151
|
+
if not other:
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
# Merge items by name
|
|
155
|
+
if other.items:
|
|
156
|
+
item_map = {item.name: item for item in self.items}
|
|
157
|
+
for item in other.items:
|
|
158
|
+
# Overwrite or merge aliases? Let's overwrite for simplicity/consistency
|
|
159
|
+
item_map[item.name] = item
|
|
160
|
+
self.items = list(item_map.values())
|
|
161
|
+
|
|
162
|
+
# Strict mode: logic? maybe strict overrides?
|
|
163
|
+
# Let's say if ANY config asks for strict, it is strict? Or last one wins (project)?
|
|
164
|
+
# Default merge is usually override.
|
|
165
|
+
self.strict = other.strict
|
|
166
|
+
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
|
|
136
170
|
class StateMachineConfig(BaseModel):
|
|
137
171
|
transitions: List[TransitionConfig]
|
|
138
172
|
|
|
@@ -155,6 +189,7 @@ class MonocoConfig(BaseModel):
|
|
|
155
189
|
)
|
|
156
190
|
|
|
157
191
|
issue: IssueSchemaConfig = Field(default_factory=IssueSchemaConfig)
|
|
192
|
+
domains: DomainConfig = Field(default_factory=DomainConfig)
|
|
158
193
|
|
|
159
194
|
@staticmethod
|
|
160
195
|
def _deep_merge(base: Dict[str, Any], update: Dict[str, Any]) -> Dict[str, Any]:
|
monoco/core/integrations.py
CHANGED
|
@@ -134,12 +134,6 @@ DEFAULT_INTEGRATIONS: Dict[str, AgentIntegration] = {
|
|
|
134
134
|
bin_name="kimi",
|
|
135
135
|
version_cmd="--version",
|
|
136
136
|
),
|
|
137
|
-
"agent": AgentIntegration(
|
|
138
|
-
key="agent",
|
|
139
|
-
name="Antigravity",
|
|
140
|
-
system_prompt_file="GEMINI.md",
|
|
141
|
-
skill_root_dir=".agent/skills/",
|
|
142
|
-
),
|
|
143
137
|
}
|
|
144
138
|
|
|
145
139
|
|
monoco/core/sync.py
CHANGED
|
@@ -20,15 +20,9 @@ def _get_targets(root: Path, config, cli_target: Optional[Path]) -> List[Path]:
|
|
|
20
20
|
targets.append(cli_target)
|
|
21
21
|
return targets
|
|
22
22
|
|
|
23
|
-
# 2.
|
|
24
|
-
if config.agent.targets:
|
|
25
|
-
for t in config.agent.targets:
|
|
26
|
-
targets.append(root / t)
|
|
27
|
-
return targets
|
|
28
|
-
|
|
29
|
-
# 3. Registry Defaults (Dynamic Detection)
|
|
23
|
+
# 2. Registry Defaults (Dynamic Detection)
|
|
30
24
|
integrations = get_active_integrations(
|
|
31
|
-
root, config_overrides=
|
|
25
|
+
root, config_overrides=None, auto_detect=True
|
|
32
26
|
)
|
|
33
27
|
|
|
34
28
|
if integrations:
|
|
@@ -69,16 +63,9 @@ def sync_command(
|
|
|
69
63
|
# 2. Collect Data
|
|
70
64
|
collected_prompts = {}
|
|
71
65
|
|
|
72
|
-
# Filter features based on config if specified
|
|
66
|
+
# Filter features based on config if specified (Deprecated: agent config removed)
|
|
73
67
|
all_features = registry.get_features()
|
|
74
|
-
active_features =
|
|
75
|
-
|
|
76
|
-
if config.agent.includes:
|
|
77
|
-
for f in all_features:
|
|
78
|
-
if f.name in config.agent.includes:
|
|
79
|
-
active_features.append(f)
|
|
80
|
-
else:
|
|
81
|
-
active_features = all_features
|
|
68
|
+
active_features = all_features
|
|
82
69
|
|
|
83
70
|
with console.status("[bold green]Collecting feature integration data...") as status:
|
|
84
71
|
for feature in active_features:
|
|
@@ -109,7 +96,7 @@ def sync_command(
|
|
|
109
96
|
|
|
110
97
|
# Get active integrations
|
|
111
98
|
integrations = get_active_integrations(
|
|
112
|
-
root, config_overrides=
|
|
99
|
+
root, config_overrides=None, auto_detect=True
|
|
113
100
|
)
|
|
114
101
|
|
|
115
102
|
if integrations:
|
|
@@ -231,7 +218,7 @@ def uninstall_command(
|
|
|
231
218
|
|
|
232
219
|
# Get active integrations
|
|
233
220
|
integrations = get_active_integrations(
|
|
234
|
-
root, config_overrides=
|
|
221
|
+
root, config_overrides=None, auto_detect=True
|
|
235
222
|
)
|
|
236
223
|
|
|
237
224
|
if integrations:
|
|
@@ -17,6 +17,9 @@ backlog_app = typer.Typer(help="Manage backlog operations.")
|
|
|
17
17
|
lsp_app = typer.Typer(help="LSP Server commands.")
|
|
18
18
|
app.add_typer(backlog_app, name="backlog")
|
|
19
19
|
app.add_typer(lsp_app, name="lsp")
|
|
20
|
+
from . import domain_commands
|
|
21
|
+
|
|
22
|
+
app.add_typer(domain_commands.app, name="domain")
|
|
20
23
|
console = Console()
|
|
21
24
|
|
|
22
25
|
|
|
@@ -45,12 +48,12 @@ def create(
|
|
|
45
48
|
),
|
|
46
49
|
sprint: Optional[str] = typer.Option(None, "--sprint", help="Sprint ID"),
|
|
47
50
|
tags: List[str] = typer.Option([], "--tag", help="Tags"),
|
|
51
|
+
domains: List[str] = typer.Option([], "--domain", help="Domains"),
|
|
48
52
|
root: Optional[str] = typer.Option(
|
|
49
53
|
None, "--root", help="Override issues root directory"
|
|
50
54
|
),
|
|
51
55
|
json: AgentOutput = False,
|
|
52
56
|
):
|
|
53
|
-
"""Create a new issue."""
|
|
54
57
|
"""Create a new issue."""
|
|
55
58
|
config = get_config()
|
|
56
59
|
issues_root = _resolve_issues_root(config, root)
|
|
@@ -79,6 +82,7 @@ def create(
|
|
|
79
82
|
stage=stage,
|
|
80
83
|
dependencies=dependencies,
|
|
81
84
|
related=related,
|
|
85
|
+
domains=domains,
|
|
82
86
|
subdir=subdir,
|
|
83
87
|
sprint=sprint,
|
|
84
88
|
tags=tags,
|
|
@@ -99,6 +103,25 @@ def create(
|
|
|
99
103
|
)
|
|
100
104
|
console.print(f"Path: {rel_path}")
|
|
101
105
|
|
|
106
|
+
# Prompt for Language
|
|
107
|
+
target_langs = config.i18n.target_langs
|
|
108
|
+
primary_lang = target_langs[0] if target_langs else "en"
|
|
109
|
+
|
|
110
|
+
# Simple mapping for display
|
|
111
|
+
lang_display = {
|
|
112
|
+
"zh": "Chinese (Simplified)",
|
|
113
|
+
"en": "English",
|
|
114
|
+
"ja": "Japanese",
|
|
115
|
+
}.get(primary_lang, primary_lang)
|
|
116
|
+
|
|
117
|
+
console.print(
|
|
118
|
+
f"\n[bold yellow]Agent Hint:[/bold yellow] Please fill the ticket content in [bold cyan]{lang_display}[/bold cyan]."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
except ValueError as e:
|
|
122
|
+
OutputManager.error(str(e))
|
|
123
|
+
raise typer.Exit(code=1)
|
|
124
|
+
|
|
102
125
|
except ValueError as e:
|
|
103
126
|
OutputManager.error(str(e))
|
|
104
127
|
raise typer.Exit(code=1)
|
monoco/features/issue/core.py
CHANGED
|
@@ -53,28 +53,108 @@ def _get_slug(title: str) -> str:
|
|
|
53
53
|
return slug
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
def parse_issue(file_path: Path) -> Optional[IssueMetadata]:
|
|
56
|
+
def parse_issue(file_path: Path, raise_error: bool = False) -> Optional[IssueMetadata]:
|
|
57
57
|
if not file_path.suffix == ".md":
|
|
58
58
|
return None
|
|
59
59
|
|
|
60
60
|
content = file_path.read_text()
|
|
61
61
|
match = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
|
|
62
62
|
if not match:
|
|
63
|
+
if raise_error:
|
|
64
|
+
raise ValueError(f"No frontmatter found in {file_path.name}")
|
|
63
65
|
return None
|
|
64
66
|
|
|
65
67
|
try:
|
|
66
68
|
data = yaml.safe_load(match.group(1))
|
|
67
69
|
if not isinstance(data, dict):
|
|
70
|
+
if raise_error:
|
|
71
|
+
raise ValueError(f"Frontmatter is not a dictionary in {file_path.name}")
|
|
68
72
|
return None
|
|
69
73
|
|
|
70
74
|
data["path"] = str(file_path.absolute())
|
|
71
75
|
meta = IssueMetadata(**data)
|
|
72
76
|
meta.actions = get_available_actions(meta)
|
|
73
77
|
return meta
|
|
74
|
-
except Exception:
|
|
78
|
+
except Exception as e:
|
|
79
|
+
if raise_error:
|
|
80
|
+
raise e
|
|
75
81
|
return None
|
|
76
82
|
|
|
77
83
|
|
|
84
|
+
def _serialize_metadata(metadata: IssueMetadata) -> str:
|
|
85
|
+
"""
|
|
86
|
+
Centralized serialization logic to ensure explicit fields and correct ordering.
|
|
87
|
+
"""
|
|
88
|
+
# Serialize metadata
|
|
89
|
+
# We want explicit fields even if None/Empty to enforce schema awareness
|
|
90
|
+
data = metadata.model_dump(
|
|
91
|
+
exclude_none=True, mode="json", exclude={"actions", "path"}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Force explicit keys if missing (due to exclude_none or defaults)
|
|
95
|
+
if "parent" not in data:
|
|
96
|
+
data["parent"] = None
|
|
97
|
+
if "dependencies" not in data:
|
|
98
|
+
data["dependencies"] = []
|
|
99
|
+
if "related" not in data:
|
|
100
|
+
data["related"] = []
|
|
101
|
+
if "domains" not in data:
|
|
102
|
+
data["domains"] = []
|
|
103
|
+
if "files" not in data:
|
|
104
|
+
data["files"] = []
|
|
105
|
+
|
|
106
|
+
# Custom YAML Dumper to preserve None as 'null' and order
|
|
107
|
+
# Helper to order keys: id, uid, type, status, stage, title, ... graph ...
|
|
108
|
+
# Simple sort isn't enough, we rely on insertion order (Python 3.7+)
|
|
109
|
+
ordered_data = {
|
|
110
|
+
k: data[k]
|
|
111
|
+
for k in [
|
|
112
|
+
"id",
|
|
113
|
+
"uid",
|
|
114
|
+
"type",
|
|
115
|
+
"status",
|
|
116
|
+
"stage",
|
|
117
|
+
"title",
|
|
118
|
+
"created_at",
|
|
119
|
+
"updated_at",
|
|
120
|
+
]
|
|
121
|
+
if k in data
|
|
122
|
+
}
|
|
123
|
+
# Add graph fields
|
|
124
|
+
for k in [
|
|
125
|
+
"priority",
|
|
126
|
+
"parent",
|
|
127
|
+
"dependencies",
|
|
128
|
+
"related",
|
|
129
|
+
"domains",
|
|
130
|
+
"tags",
|
|
131
|
+
"files",
|
|
132
|
+
]:
|
|
133
|
+
if k in data:
|
|
134
|
+
ordered_data[k] = data[k]
|
|
135
|
+
elif k in ["dependencies", "related", "domains", "tags", "files"]:
|
|
136
|
+
ordered_data[k] = []
|
|
137
|
+
elif k == "parent":
|
|
138
|
+
ordered_data[k] = None
|
|
139
|
+
|
|
140
|
+
# Add remaining
|
|
141
|
+
for k, v in data.items():
|
|
142
|
+
if k not in ordered_data:
|
|
143
|
+
ordered_data[k] = v
|
|
144
|
+
|
|
145
|
+
yaml_header = yaml.dump(
|
|
146
|
+
ordered_data, sort_keys=False, allow_unicode=True, default_flow_style=False
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Inject Comments for guidance (replace keys with key+comment)
|
|
150
|
+
if "parent" in ordered_data and ordered_data["parent"] is None:
|
|
151
|
+
yaml_header = yaml_header.replace(
|
|
152
|
+
"parent: null", "parent: null # <EPIC-ID> Optional"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return yaml_header
|
|
156
|
+
|
|
157
|
+
|
|
78
158
|
def parse_issue_detail(file_path: Path) -> Optional[IssueDetail]:
|
|
79
159
|
if not file_path.suffix == ".md":
|
|
80
160
|
return None
|
|
@@ -127,6 +207,7 @@ def create_issue_file(
|
|
|
127
207
|
stage: Optional[IssueStage] = None,
|
|
128
208
|
dependencies: List[str] = [],
|
|
129
209
|
related: List[str] = [],
|
|
210
|
+
domains: List[str] = [],
|
|
130
211
|
subdir: Optional[str] = None,
|
|
131
212
|
sprint: Optional[str] = None,
|
|
132
213
|
tags: List[str] = [],
|
|
@@ -149,8 +230,7 @@ def create_issue_file(
|
|
|
149
230
|
|
|
150
231
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
151
232
|
|
|
152
|
-
# Auto-Populate Tags with required IDs
|
|
153
|
-
# Ensure they are prefixed with '#' for tagging convention if not present (usually tags are just strings, but user asked for #ID)
|
|
233
|
+
# Auto-Populate Tags with required IDs
|
|
154
234
|
auto_tags = set(tags) if tags else set()
|
|
155
235
|
|
|
156
236
|
# 1. Add Parent
|
|
@@ -165,15 +245,14 @@ def create_issue_file(
|
|
|
165
245
|
for rel in related:
|
|
166
246
|
auto_tags.add(f"#{rel}")
|
|
167
247
|
|
|
168
|
-
# 4. Add Self
|
|
169
|
-
# Note: issue_id is generated just above
|
|
248
|
+
# 4. Add Self
|
|
170
249
|
auto_tags.add(f"#{issue_id}")
|
|
171
250
|
|
|
172
251
|
final_tags = sorted(list(auto_tags))
|
|
173
252
|
|
|
174
253
|
metadata = IssueMetadata(
|
|
175
254
|
id=issue_id,
|
|
176
|
-
uid=generate_uid(),
|
|
255
|
+
uid=generate_uid(),
|
|
177
256
|
type=issue_type,
|
|
178
257
|
status=status,
|
|
179
258
|
stage=stage,
|
|
@@ -181,43 +260,23 @@ def create_issue_file(
|
|
|
181
260
|
parent=parent,
|
|
182
261
|
dependencies=dependencies,
|
|
183
262
|
related=related,
|
|
263
|
+
domains=domains,
|
|
184
264
|
sprint=sprint,
|
|
185
265
|
tags=final_tags,
|
|
186
266
|
opened_at=current_time() if status == IssueStatus.OPEN else None,
|
|
187
267
|
)
|
|
188
268
|
|
|
189
|
-
# Enforce lifecycle policies
|
|
269
|
+
# Enforce lifecycle policies
|
|
190
270
|
from .engine import get_engine
|
|
191
271
|
|
|
192
272
|
get_engine().enforce_policy(metadata)
|
|
193
273
|
|
|
194
274
|
# Serialize metadata
|
|
195
|
-
|
|
196
|
-
yaml_header = yaml.dump(
|
|
197
|
-
metadata.model_dump(
|
|
198
|
-
exclude_none=True, mode="json", exclude={"actions", "path"}
|
|
199
|
-
),
|
|
200
|
-
sort_keys=False,
|
|
201
|
-
allow_unicode=True,
|
|
202
|
-
)
|
|
203
|
-
|
|
204
|
-
# Inject Self-Documenting Hints (Interactive Frontmatter)
|
|
205
|
-
if "parent:" not in yaml_header:
|
|
206
|
-
yaml_header += "# parent: <EPIC-ID> # Optional: Parent Issue ID\n"
|
|
207
|
-
if "solution:" not in yaml_header:
|
|
208
|
-
yaml_header += "# solution: null # Required for Closed state (implemented, cancelled, etc.)\n"
|
|
209
|
-
|
|
210
|
-
if "dependencies:" not in yaml_header:
|
|
211
|
-
yaml_header += "# dependencies: [] # List of dependency IDs\n"
|
|
212
|
-
if "related:" not in yaml_header:
|
|
213
|
-
yaml_header += "# related: [] # List of related issue IDs\n"
|
|
214
|
-
if "files:" not in yaml_header:
|
|
215
|
-
yaml_header += "# files: [] # List of modified files\n"
|
|
275
|
+
yaml_header = _serialize_metadata(metadata)
|
|
216
276
|
|
|
217
277
|
slug = _get_slug(title)
|
|
218
278
|
filename = f"{issue_id}-{slug}.md"
|
|
219
279
|
|
|
220
|
-
# Enhanced Template with Instructional Comments
|
|
221
280
|
file_content = f"""---
|
|
222
281
|
{yaml_header}---
|
|
223
282
|
|
|
@@ -249,7 +308,6 @@ def create_issue_file(
|
|
|
249
308
|
file_path = target_dir / filename
|
|
250
309
|
file_path.write_text(file_content)
|
|
251
310
|
|
|
252
|
-
# Inject path into returned metadata
|
|
253
311
|
metadata.path = str(file_path.absolute())
|
|
254
312
|
|
|
255
313
|
return metadata, file_path
|
|
@@ -512,14 +570,7 @@ def update_issue(
|
|
|
512
570
|
raise ValueError(f"Failed to validate updated metadata: {e}")
|
|
513
571
|
|
|
514
572
|
# Serialize back
|
|
515
|
-
|
|
516
|
-
new_yaml = yaml.dump(
|
|
517
|
-
updated_meta.model_dump(
|
|
518
|
-
exclude_none=True, mode="json", exclude={"actions", "path"}
|
|
519
|
-
),
|
|
520
|
-
sort_keys=False,
|
|
521
|
-
allow_unicode=True,
|
|
522
|
-
)
|
|
573
|
+
new_yaml = _serialize_metadata(updated_meta)
|
|
523
574
|
|
|
524
575
|
# Reconstruct File
|
|
525
576
|
match_header = re.search(r"^---(.*?)---", content, re.DOTALL | re.MULTILINE)
|
|
@@ -113,6 +113,7 @@ class IssueFrontmatter(BaseModel):
|
|
|
113
113
|
parent: Optional[str] = None
|
|
114
114
|
dependencies: List[str] = Field(default_factory=list)
|
|
115
115
|
related: List[str] = Field(default_factory=list)
|
|
116
|
+
domains: List[str] = Field(default_factory=list)
|
|
116
117
|
tags: List[str] = Field(default_factory=list)
|
|
117
118
|
solution: Optional[IssueSolution] = None
|
|
118
119
|
isolation: Optional[IssueIsolation] = None
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.table import Table
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from monoco.features.issue.domain_service import DomainService
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(help="Manage domain ontology.")
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command("list")
|
|
11
|
+
def list_domains():
|
|
12
|
+
"""List defined domains and aliases."""
|
|
13
|
+
service = DomainService()
|
|
14
|
+
config = service.config
|
|
15
|
+
|
|
16
|
+
table = Table(title=f"Domain Ontology (Strict: {config.strict})")
|
|
17
|
+
table.add_column("Canonical Name", style="bold cyan")
|
|
18
|
+
table.add_column("Description", style="white")
|
|
19
|
+
table.add_column("Aliases", style="yellow")
|
|
20
|
+
|
|
21
|
+
for item in config.items:
|
|
22
|
+
table.add_row(
|
|
23
|
+
item.name,
|
|
24
|
+
item.description or "",
|
|
25
|
+
", ".join(item.aliases) if item.aliases else "-",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
console.print(table)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("check")
|
|
32
|
+
def check_domain(domain: str = typer.Argument(..., help="Domain name to check")):
|
|
33
|
+
"""Check if a domain is valid and resolve it."""
|
|
34
|
+
service = DomainService()
|
|
35
|
+
|
|
36
|
+
if service.is_canonical(domain):
|
|
37
|
+
console.print(f"[green]✔ '{domain}' is a canonical domain.[/green]")
|
|
38
|
+
elif service.is_alias(domain):
|
|
39
|
+
canonical = service.get_canonical(domain)
|
|
40
|
+
console.print(f"[yellow]➜ '{domain}' is an alias for '{canonical}'.[/yellow]")
|
|
41
|
+
else:
|
|
42
|
+
if service.config.strict:
|
|
43
|
+
console.print(f"[red]✘ '{domain}' is NOT a valid domain.[/red]")
|
|
44
|
+
else:
|
|
45
|
+
console.print(
|
|
46
|
+
f"[yellow]⚠ '{domain}' is undefined (Strict Mode: OFF).[/yellow]"
|
|
47
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from typing import Dict, Optional, Set
|
|
2
|
+
from monoco.core.config import get_config, DomainConfig
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DomainService:
|
|
6
|
+
"""
|
|
7
|
+
Service for managing domain ontology, aliases, and validation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, config: Optional[DomainConfig] = None):
|
|
11
|
+
self.config = config or get_config().domains
|
|
12
|
+
self._alias_map: Dict[str, str] = {}
|
|
13
|
+
self._canonical_domains: Set[str] = set()
|
|
14
|
+
self._build_index()
|
|
15
|
+
|
|
16
|
+
def _build_index(self):
|
|
17
|
+
self._alias_map.clear()
|
|
18
|
+
self._canonical_domains.clear()
|
|
19
|
+
|
|
20
|
+
for item in self.config.items:
|
|
21
|
+
self._canonical_domains.add(item.name)
|
|
22
|
+
for alias in item.aliases:
|
|
23
|
+
self._alias_map[alias] = item.name
|
|
24
|
+
|
|
25
|
+
def reload(self):
|
|
26
|
+
"""Reload configuration (if get_config returns new instance referenced)"""
|
|
27
|
+
# Usually get_config() returns the singleton. If singleton updates, we might see it?
|
|
28
|
+
# But we stored self.config.
|
|
29
|
+
# Ideally we fetch fresh config if we want reload.
|
|
30
|
+
self.config = get_config().domains
|
|
31
|
+
self._build_index()
|
|
32
|
+
|
|
33
|
+
def is_defined(self, domain: str) -> bool:
|
|
34
|
+
"""Check if domain is known (canonical or alias)."""
|
|
35
|
+
return domain in self._canonical_domains or domain in self._alias_map
|
|
36
|
+
|
|
37
|
+
def is_canonical(self, domain: str) -> bool:
|
|
38
|
+
"""Check if domain is a canonical name."""
|
|
39
|
+
return domain in self._canonical_domains
|
|
40
|
+
|
|
41
|
+
def is_alias(self, domain: str) -> bool:
|
|
42
|
+
"""Check if domain is a known alias."""
|
|
43
|
+
return domain in self._alias_map
|
|
44
|
+
|
|
45
|
+
def get_canonical(self, domain: str) -> Optional[str]:
|
|
46
|
+
"""
|
|
47
|
+
Resolve alias to canonical name.
|
|
48
|
+
Returns Canonical Name if found.
|
|
49
|
+
Returns None if it is not an alias (could be canonical or unknown).
|
|
50
|
+
"""
|
|
51
|
+
return self._alias_map.get(domain)
|
|
52
|
+
|
|
53
|
+
def normalize(self, domain: str) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Normalize domain: return canonical if it's an alias, else return original.
|
|
56
|
+
"""
|
|
57
|
+
return self._alias_map.get(domain, domain)
|
|
58
|
+
|
|
59
|
+
def suggest_correction(self, domain: str) -> Optional[str]:
|
|
60
|
+
"""
|
|
61
|
+
Suggest a correction for an unknown domain (Fuzzy matching).
|
|
62
|
+
"""
|
|
63
|
+
# Simple fuzzy match implementation (optional)
|
|
64
|
+
# Using simple containment or levenshtein if available?
|
|
65
|
+
# Let's keep it simple: check if domain is substring of canonical?
|
|
66
|
+
# Or simple typo check loop.
|
|
67
|
+
|
|
68
|
+
# For now, just return None as fuzzy match is optional and requires dependency or complex logic
|
|
69
|
+
return None
|
monoco/features/issue/linter.py
CHANGED
|
@@ -7,7 +7,7 @@ import re
|
|
|
7
7
|
from monoco.core import git
|
|
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
|
|
|
@@ -68,15 +68,29 @@ def check_integrity(issues_root: Path, recursive: bool = False) -> List[Diagnost
|
|
|
68
68
|
files.extend(status_dir.rglob("*.md"))
|
|
69
69
|
|
|
70
70
|
for f in files:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
71
|
+
try:
|
|
72
|
+
meta = core.parse_issue(f, raise_error=True)
|
|
73
|
+
if meta:
|
|
74
|
+
local_id = meta.id
|
|
75
|
+
full_id = f"{project_name}::{local_id}"
|
|
76
|
+
|
|
77
|
+
all_issue_ids.add(local_id)
|
|
78
|
+
all_issue_ids.add(full_id)
|
|
79
|
+
|
|
80
|
+
project_issues.append((f, meta))
|
|
81
|
+
except Exception as e:
|
|
82
|
+
# Report parsing failure as diagnostic
|
|
83
|
+
d = Diagnostic(
|
|
84
|
+
range=Range(
|
|
85
|
+
start=Position(line=0, character=0),
|
|
86
|
+
end=Position(line=0, character=0),
|
|
87
|
+
),
|
|
88
|
+
message=f"Schema Error: {str(e)}",
|
|
89
|
+
severity=DiagnosticSeverity.Error,
|
|
90
|
+
source="System",
|
|
91
|
+
)
|
|
92
|
+
d.data = {"path": f}
|
|
93
|
+
diagnostics.append(d)
|
|
80
94
|
return project_issues
|
|
81
95
|
|
|
82
96
|
from monoco.core.config import get_config
|
|
@@ -193,7 +207,7 @@ def run_lint(
|
|
|
193
207
|
|
|
194
208
|
# Parse and validate file
|
|
195
209
|
try:
|
|
196
|
-
meta = core.parse_issue(file)
|
|
210
|
+
meta = core.parse_issue(file, raise_error=True)
|
|
197
211
|
if not meta:
|
|
198
212
|
console.print(
|
|
199
213
|
f"[yellow]Warning:[/yellow] Failed to parse issue metadata from {file_path}. Skipping."
|
|
@@ -315,6 +329,36 @@ def run_lint(
|
|
|
315
329
|
new_content = "\n".join(lines) + "\n"
|
|
316
330
|
has_changes = True
|
|
317
331
|
|
|
332
|
+
if (
|
|
333
|
+
"Hierarchy Violation" in d.message
|
|
334
|
+
and "Epics must have a parent" in d.message
|
|
335
|
+
):
|
|
336
|
+
try:
|
|
337
|
+
fm_match = re.search(
|
|
338
|
+
r"^---(.*?)---", new_content, re.DOTALL | re.MULTILINE
|
|
339
|
+
)
|
|
340
|
+
if fm_match:
|
|
341
|
+
import yaml
|
|
342
|
+
|
|
343
|
+
fm_text = fm_match.group(1)
|
|
344
|
+
data = yaml.safe_load(fm_text) or {}
|
|
345
|
+
|
|
346
|
+
# Default to EPIC-0000
|
|
347
|
+
data["parent"] = "EPIC-0000"
|
|
348
|
+
|
|
349
|
+
new_fm_text = yaml.dump(
|
|
350
|
+
data, sort_keys=False, allow_unicode=True
|
|
351
|
+
)
|
|
352
|
+
# Replace FM block
|
|
353
|
+
new_content = new_content.replace(
|
|
354
|
+
fm_match.group(1), "\n" + new_fm_text
|
|
355
|
+
)
|
|
356
|
+
has_changes = True
|
|
357
|
+
except Exception as ex:
|
|
358
|
+
console.print(
|
|
359
|
+
f"[red]Failed to fix parent hierarchy: {ex}[/red]"
|
|
360
|
+
)
|
|
361
|
+
|
|
318
362
|
if "Tag Check: Missing required context tags" in d.message:
|
|
319
363
|
# Extract missing tags from message
|
|
320
364
|
# Message format: "Tag Check: Missing required context tags: #TAG1, #TAG2"
|
|
@@ -426,6 +470,70 @@ def run_lint(
|
|
|
426
470
|
except Exception as e:
|
|
427
471
|
console.print(f"[red]Failed to fix domains for {path.name}: {e}[/red]")
|
|
428
472
|
|
|
473
|
+
# Domain Alias Fix
|
|
474
|
+
try:
|
|
475
|
+
alias_fixes = [
|
|
476
|
+
d for d in current_file_diags if "Domain Alias:" in d.message
|
|
477
|
+
]
|
|
478
|
+
if alias_fixes:
|
|
479
|
+
fm_match = re.search(
|
|
480
|
+
r"^---(.*?)---", new_content, re.DOTALL | re.MULTILINE
|
|
481
|
+
)
|
|
482
|
+
if fm_match:
|
|
483
|
+
import yaml
|
|
484
|
+
|
|
485
|
+
fm_text = fm_match.group(1)
|
|
486
|
+
data = yaml.safe_load(fm_text) or {}
|
|
487
|
+
|
|
488
|
+
domain_changed = False
|
|
489
|
+
if "domains" in data and isinstance(data["domains"], list):
|
|
490
|
+
domains = data["domains"]
|
|
491
|
+
for d in alias_fixes:
|
|
492
|
+
# Parse message: Domain Alias: 'alias' is an alias for 'canonical'.
|
|
493
|
+
m = re.search(
|
|
494
|
+
r"Domain Alias: '([^']+)' is an alias for '([^']+)'",
|
|
495
|
+
d.message,
|
|
496
|
+
)
|
|
497
|
+
if m:
|
|
498
|
+
old_d = m.group(1)
|
|
499
|
+
new_d = m.group(2)
|
|
500
|
+
|
|
501
|
+
if old_d in domains:
|
|
502
|
+
domains = [
|
|
503
|
+
new_d if x == old_d else x for x in domains
|
|
504
|
+
]
|
|
505
|
+
domain_changed = True
|
|
506
|
+
|
|
507
|
+
if domain_changed:
|
|
508
|
+
data["domains"] = domains
|
|
509
|
+
new_fm_text = yaml.dump(
|
|
510
|
+
data, sort_keys=False, allow_unicode=True
|
|
511
|
+
)
|
|
512
|
+
new_content = new_content.replace(
|
|
513
|
+
fm_match.group(1), "\n" + new_fm_text
|
|
514
|
+
)
|
|
515
|
+
has_changes = True
|
|
516
|
+
|
|
517
|
+
# Write immediately if not handled by previous block?
|
|
518
|
+
# We are in standard flow where has_changes flag handles write at end of loop?
|
|
519
|
+
# Wait, the previous block (Missing domains) logic wrote internally ONLY if has_changes.
|
|
520
|
+
# AND it reset has_changes=False at start of try?
|
|
521
|
+
# Actually the previous block structure was separate try-except blocks.
|
|
522
|
+
# But here I am inserting AFTER the Missing Domains try-except (which was lines 390-442).
|
|
523
|
+
# But I need to write if I changed it.
|
|
524
|
+
path.write_text(new_content)
|
|
525
|
+
if not any(path == p for p in processed_paths):
|
|
526
|
+
fixed_count += 1
|
|
527
|
+
processed_paths.add(path)
|
|
528
|
+
console.print(
|
|
529
|
+
f"[dim]Fixed (Domain Alias): {path.name}[/dim]"
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
except Exception as e:
|
|
533
|
+
console.print(
|
|
534
|
+
f"[red]Failed to fix domain aliases for {path.name}: {e}[/red]"
|
|
535
|
+
)
|
|
536
|
+
|
|
429
537
|
console.print(f"[green]Applied auto-fixes to {fixed_count} files.[/green]")
|
|
430
538
|
|
|
431
539
|
# Re-run validation to verify
|