arian 0.5.0__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 (61) hide show
  1. arian/__init__.py +7 -0
  2. arian/__main__.py +5 -0
  3. arian/bootstrap/__init__.py +5 -0
  4. arian/bootstrap/logging.py +278 -0
  5. arian/controller/__init__.py +7 -0
  6. arian/controller/cli/__init__.py +7 -0
  7. arian/controller/cli/app.py +330 -0
  8. arian/domain/__init__.py +51 -0
  9. arian/domain/context/__init__.py +15 -0
  10. arian/domain/context/models.py +306 -0
  11. arian/domain/exceptions.py +54 -0
  12. arian/domain/protocols.py +104 -0
  13. arian/domain/repository/__init__.py +17 -0
  14. arian/domain/repository/models.py +115 -0
  15. arian/domain/shared/__init__.py +15 -0
  16. arian/domain/shared/enums.py +61 -0
  17. arian/infrastructure/__init__.py +17 -0
  18. arian/infrastructure/config.py +62 -0
  19. arian/infrastructure/git/__init__.py +7 -0
  20. arian/infrastructure/git/analyzer.py +75 -0
  21. arian/infrastructure/gitignore_filter.py +64 -0
  22. arian/infrastructure/ignore/__init__.py +1 -0
  23. arian/infrastructure/ignore/default_patterns.py +20 -0
  24. arian/infrastructure/language.py +85 -0
  25. arian/infrastructure/output_path_resolver.py +26 -0
  26. arian/infrastructure/sqlite/__init__.py +7 -0
  27. arian/infrastructure/sqlite/connection.py +22 -0
  28. arian/infrastructure/tokenizer/__init__.py +7 -0
  29. arian/infrastructure/tokenizer/tokenizer.py +43 -0
  30. arian/main.py +8 -0
  31. arian/py.typed +0 -0
  32. arian/renderer/__init__.py +7 -0
  33. arian/renderer/markdown/__init__.py +7 -0
  34. arian/renderer/markdown/renderer.py +184 -0
  35. arian/repository/__init__.py +16 -0
  36. arian/repository/filesystem/__init__.py +7 -0
  37. arian/repository/filesystem/collector.py +172 -0
  38. arian/repository/index/__init__.py +11 -0
  39. arian/repository/index/memory_repository.py +116 -0
  40. arian/repository/index/protocols.py +100 -0
  41. arian/repository/index/sqlite_repository.py +255 -0
  42. arian/service/__init__.py +17 -0
  43. arian/service/analyzer/__init__.py +7 -0
  44. arian/service/analyzer/python_analyzer.py +437 -0
  45. arian/service/builder/__init__.py +7 -0
  46. arian/service/builder/context_builder.py +200 -0
  47. arian/service/classifier/__init__.py +7 -0
  48. arian/service/classifier/file_classifier.py +180 -0
  49. arian/service/context/__init__.py +1 -0
  50. arian/service/context/materializer.py +216 -0
  51. arian/service/planner/__init__.py +7 -0
  52. arian/service/planner/context_planner.py +475 -0
  53. arian/service/summary/__init__.py +5 -0
  54. arian/service/summary/summary_service.py +114 -0
  55. arian/template/__init__.py +1 -0
  56. arian-0.5.0.dist-info/METADATA +186 -0
  57. arian-0.5.0.dist-info/RECORD +61 -0
  58. arian-0.5.0.dist-info/WHEEL +5 -0
  59. arian-0.5.0.dist-info/entry_points.txt +2 -0
  60. arian-0.5.0.dist-info/licenses/LICENSE +190 -0
  61. arian-0.5.0.dist-info/top_level.txt +1 -0
arian/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Arian - repository intelligence and context planning engine."""
2
+
3
+ from arian.controller.cli import app
4
+
5
+ __all__ = [
6
+ "app",
7
+ ]
arian/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Entry point for `python -m arian`."""
2
+
3
+ from arian.main import main
4
+
5
+ main()
@@ -0,0 +1,5 @@
1
+ """Bootstrap layer for Arian — startup wiring (logging)."""
2
+
3
+ from arian.bootstrap.logging import configure_logging
4
+
5
+ __all__ = ["configure_logging"]
@@ -0,0 +1,278 @@
1
+ """Logging initialization — configures stdlib logging for Arian.
2
+
3
+ Project rule
4
+ ------------
5
+ Never instantiate, attach, or reconfigure handlers outside this module.
6
+ Business code must only call ``logging.getLogger(__name__)`` and emit records.
7
+ All wiring (handlers, formatters, filters, QueueListener) lives here.
8
+
9
+ Invariants
10
+ ----------
11
+ 1. Only the **root** logger owns output handlers.
12
+ 2. Named loggers set level, clear handlers, and **propagate** to root.
13
+ 3. ``configure_logging()`` is called **once** per process at startup.
14
+ A second call with ``async_logging=True`` would start another
15
+ QueueListener without stopping the first.
16
+
17
+ Formatting policy
18
+ -----------------
19
+ Logging format is selected internally by severity:
20
+
21
+ Diagnostic (< INFO): LEVEL WHEN WHERE RESOURCE : MESSAGE
22
+ Operational (>= INFO): LEVEL WHEN RESOURCE : MESSAGE
23
+
24
+ WHERE = filename:lineno (diagnostic only)
25
+ WHEN = UTC ISO-8601 timestamp
26
+ RESOURCE = optional extra field (e.g. ``resource="model_id=bert"``)
27
+
28
+ File logging
29
+ ------------
30
+ When ``LoggingConfig.log_dir`` is set, logs are written to:
31
+ <log_dir>/arian.log
32
+
33
+ Files rotate at ``max_bytes`` with ``backup_count`` backups.
34
+ Log directory is created automatically if it does not exist.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from datetime import UTC
40
+ from datetime import datetime
41
+ import logging
42
+ import logging.config
43
+ import logging.handlers
44
+ from pathlib import Path
45
+ import queue
46
+ from typing import Any
47
+
48
+ from arian.infrastructure.config import LoggingConfig
49
+
50
+ APPLICATION_LOGGER_NAME = "arian"
51
+
52
+ _PROPAGATING_LOGGERS = (APPLICATION_LOGGER_NAME,)
53
+
54
+ _MODULE = "arian.bootstrap.logging"
55
+
56
+
57
+ def _format_utc_timestamp(a_epoch_seconds: float) -> str:
58
+ """Format Unix epoch seconds as canonical UTC ISO-8601 (microseconds + Z).
59
+
60
+ Args:
61
+ a_epoch_seconds: Unix epoch timestamp.
62
+
63
+ Returns:
64
+ Formatted string like ``2026-07-18T23:45:12.345678Z``.
65
+ """
66
+ dt: datetime = datetime.fromtimestamp(a_epoch_seconds, tz=UTC)
67
+ return dt.astimezone(UTC).isoformat(timespec="microseconds").replace("+00:00", "Z")
68
+
69
+
70
+ class IsoUtcFormatter(logging.Formatter):
71
+ """UTC ISO-8601 timestamp formatter."""
72
+
73
+ def formatTime( # a-prefix-ignore: stdlib Formatter.signature # noqa: N802
74
+ self,
75
+ record: logging.LogRecord,
76
+ datefmt: str | None = None, # noqa: ARG002 — required by Formatter API
77
+ ) -> str:
78
+ """Format record creation time as canonical UTC ISO form.
79
+
80
+ Args:
81
+ record: Log record to format.
82
+ datefmt: Unused, required by Formatter API.
83
+
84
+ Returns:
85
+ UTC ISO-8601 timestamp string.
86
+ """
87
+ return _format_utc_timestamp(record.created)
88
+
89
+
90
+ class ResourceFilter(logging.Filter):
91
+ """Inject format-safe ``resource`` attribute; always admit the record."""
92
+
93
+ def filter(self, record: logging.LogRecord) -> bool: # a-prefix-ignore: stdlib Filter.signature
94
+ """Attach a format-safe ``resource`` attribute; always admit the record.
95
+
96
+ Args:
97
+ record: Log record to filter.
98
+
99
+ Returns:
100
+ Always True.
101
+ """
102
+ resource = getattr(record, "resource", None)
103
+ if resource:
104
+ record.resource = f" {resource}"
105
+ else:
106
+ record.resource = ""
107
+ return True
108
+
109
+
110
+ class DiagnosticLevelFilter(logging.Filter):
111
+ """Admit Diagnostic (< INFO) records only."""
112
+
113
+ def filter(self, record: logging.LogRecord) -> bool: # a-prefix-ignore: stdlib Filter.signature
114
+ """Return True for Diagnostic (< INFO) levels only.
115
+
116
+ Args:
117
+ record: Log record to filter.
118
+
119
+ Returns:
120
+ True if record level is below INFO.
121
+ """
122
+ return record.levelno < logging.INFO
123
+
124
+
125
+ def _build_logging_config(a_config: LoggingConfig) -> dict[str, Any]:
126
+ """Build dictConfig: named loggers propagate to root; root owns handlers.
127
+
128
+ Handlers:
129
+ console_debug: diagnostic (< INFO) to stderr
130
+ console_ops: operational (>= INFO) to stderr
131
+ file: all levels to rotating file (if log_dir is set)
132
+
133
+ Args:
134
+ a_config: Validated logging configuration.
135
+
136
+ Returns:
137
+ Dictionary suitable for logging.config.dictConfig.
138
+ """
139
+ level: str = a_config.level
140
+
141
+ handlers: dict[str, dict[str, Any]] = {
142
+ "console_debug": {
143
+ "class": "logging.StreamHandler",
144
+ "level": "NOTSET",
145
+ "formatter": "diagnostic",
146
+ "filters": ["diagnostic_level", "resource"],
147
+ "stream": "ext://sys.stderr",
148
+ },
149
+ "console_ops": {
150
+ "class": "logging.StreamHandler",
151
+ "level": "INFO",
152
+ "formatter": "operational",
153
+ "filters": ["resource"],
154
+ "stream": "ext://sys.stderr",
155
+ },
156
+ }
157
+
158
+ root_handlers: list[str] = ["console_debug", "console_ops"]
159
+
160
+ if a_config.log_dir is not None:
161
+ log_dir: Path = Path(a_config.log_dir).expanduser()
162
+ log_dir.mkdir(parents=True, exist_ok=True)
163
+ log_file: Path = log_dir / "arian.log"
164
+
165
+ handlers["file"] = {
166
+ "class": "logging.handlers.RotatingFileHandler",
167
+ "level": "NOTSET",
168
+ "formatter": "file",
169
+ "filters": ["resource"],
170
+ "filename": str(log_file),
171
+ "maxBytes": a_config.max_bytes,
172
+ "backupCount": a_config.backup_count,
173
+ "encoding": "utf-8",
174
+ }
175
+ root_handlers.append("file")
176
+
177
+ return {
178
+ "version": 1,
179
+ "disable_existing_loggers": False,
180
+ "filters": {
181
+ "resource": {
182
+ "()": f"{_MODULE}.ResourceFilter",
183
+ },
184
+ "diagnostic_level": {
185
+ "()": f"{_MODULE}.DiagnosticLevelFilter",
186
+ },
187
+ },
188
+ "formatters": {
189
+ "diagnostic": {
190
+ "()": f"{_MODULE}.IsoUtcFormatter",
191
+ "format": "%(levelname)s %(asctime)s %(filename)s:%(lineno)d%(resource)s : %(message)s",
192
+ },
193
+ "operational": {
194
+ "()": f"{_MODULE}.IsoUtcFormatter",
195
+ "format": "%(levelname)s %(asctime)s%(resource)s : %(message)s",
196
+ },
197
+ "file": {
198
+ "()": f"{_MODULE}.IsoUtcFormatter",
199
+ "format": "%(levelname)s %(asctime)s %(filename)s:%(lineno)d%(resource)s : %(message)s",
200
+ },
201
+ },
202
+ "handlers": handlers,
203
+ "loggers": {
204
+ name: {
205
+ "level": level,
206
+ "handlers": [],
207
+ "propagate": True,
208
+ }
209
+ for name in _PROPAGATING_LOGGERS
210
+ },
211
+ "root": {
212
+ "level": "WARNING",
213
+ "handlers": root_handlers,
214
+ },
215
+ }
216
+
217
+
218
+ def _replace_handlers(a_logger: logging.Logger, a_handler: logging.Handler) -> None:
219
+ """Replace all handlers on *a_logger* with *a_handler*.
220
+
221
+ Args:
222
+ a_logger: Logger to modify.
223
+ a_handler: Handler to install.
224
+ """
225
+ a_logger.handlers.clear()
226
+ a_logger.addHandler(a_handler)
227
+
228
+
229
+ def _enable_async_logging() -> logging.handlers.QueueListener:
230
+ """Route root handlers through QueueHandler → QueueListener for non-blocking I/O.
231
+
232
+ Returns:
233
+ Running QueueListener instance.
234
+
235
+ Raises:
236
+ RuntimeError: If root logger has no handlers to wrap.
237
+ """
238
+ root: logging.Logger = logging.getLogger()
239
+ targets: list[logging.Handler] = [
240
+ handler for handler in root.handlers if not isinstance(handler, logging.handlers.QueueHandler)
241
+ ]
242
+ if not targets:
243
+ msg = "Cannot enable async logging: root logger has no handlers"
244
+ raise RuntimeError(msg)
245
+
246
+ log_queue: queue.SimpleQueue[logging.LogRecord] = queue.SimpleQueue()
247
+ queue_handler: logging.handlers.QueueHandler = logging.handlers.QueueHandler(log_queue)
248
+ _replace_handlers(root, queue_handler)
249
+
250
+ listener: logging.handlers.QueueListener = logging.handlers.QueueListener(
251
+ log_queue,
252
+ *targets,
253
+ respect_handler_level=True,
254
+ )
255
+ listener.start()
256
+ return listener
257
+
258
+
259
+ def configure_logging(a_config: LoggingConfig | None = None) -> logging.handlers.QueueListener | None:
260
+ """Apply dictConfig + captureWarnings once at process startup.
261
+
262
+ Returns a running ``QueueListener`` when async logging is enabled, else ``None``.
263
+
264
+ Args:
265
+ a_config: Logging configuration. Uses defaults if None.
266
+
267
+ Returns:
268
+ Running QueueListener if async_logging is enabled, None otherwise.
269
+ """
270
+ config: LoggingConfig = a_config or LoggingConfig()
271
+
272
+ logging.config.dictConfig(_build_logging_config(config))
273
+ logging.captureWarnings(True)
274
+
275
+ listener: logging.handlers.QueueListener | None = None
276
+ if config.async_logging:
277
+ listener = _enable_async_logging()
278
+ return listener
@@ -0,0 +1,7 @@
1
+ """Controller layer for Arian."""
2
+
3
+ from arian.controller.cli import app
4
+
5
+ __all__ = [
6
+ "app",
7
+ ]
@@ -0,0 +1,7 @@
1
+ """CLI controller for Arian."""
2
+
3
+ from arian.controller.cli.app import app
4
+
5
+ __all__ = [
6
+ "app",
7
+ ]
@@ -0,0 +1,330 @@
1
+ """Typer CLI application for Arian."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import logging.handlers
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from arian.bootstrap.logging import configure_logging
13
+ from arian.domain.context.models import ContextPlan
14
+ from arian.domain.context.models import ContextTask
15
+ from arian.domain.shared.enums import TokenBudget
16
+ from arian.infrastructure.config import LoggingConfig
17
+ from arian.infrastructure.ignore.default_patterns import DEFAULT_EXCLUDES
18
+ from arian.infrastructure.output_path_resolver import resolve_output_path
19
+ from arian.renderer.markdown.renderer import MarkdownRenderer
20
+ from arian.repository.filesystem.collector import FileCollector
21
+ from arian.repository.index.memory_repository import MemoryRepositoryIndex
22
+ from arian.service.analyzer.python_analyzer import PythonAnalyzer
23
+ from arian.service.builder.context_builder import ContextBuilder
24
+ from arian.service.classifier.file_classifier import FileClassifier
25
+ from arian.service.context.materializer import ContextMaterializer
26
+ from arian.service.planner.context_planner import ContextPlanner
27
+
28
+ app: typer.Typer = typer.Typer(help="Repository intelligence and context planning engine.", add_completion=False)
29
+
30
+ logger: logging.Logger = logging.getLogger(__name__)
31
+
32
+
33
+ def _parse_budget(a_value: str | None) -> int | None:
34
+ """Parse budget string into token count or None for unlimited.
35
+
36
+ Args:
37
+ a_value: Raw budget string from CLI. None, "none", or a positive integer.
38
+
39
+ Returns:
40
+ Parsed budget as int, or None for unlimited.
41
+
42
+ Raises:
43
+ typer.Exit: If value is not a valid number or is non-positive.
44
+ """
45
+ budget_value: int | None = None
46
+ if a_value is not None:
47
+ if a_value.lower() == "none":
48
+ budget_value = None
49
+ else:
50
+ try:
51
+ budget_value = int(a_value)
52
+ except ValueError:
53
+ logger.exception("Invalid budget: %s. Must be a number or 'none'.", a_value)
54
+ raise typer.Exit(code=1) from None
55
+ if budget_value <= 0:
56
+ logger.error("Budget must be > 0, got: %d", budget_value)
57
+ raise typer.Exit(code=1) from None
58
+ return budget_value
59
+
60
+
61
+ _DEFAULT_EXTENSIONS: frozenset[str] = frozenset({".py", ".md", ".txt", ".rst", ".toml", ".yaml", ".yml", ".json"})
62
+
63
+
64
+ def _run_separate(
65
+ a_builder: ContextBuilder,
66
+ a_input_paths: list[Path],
67
+ a_task: ContextTask,
68
+ a_budget: TokenBudget,
69
+ a_query: str | None,
70
+ a_root: Path,
71
+ a_output_path: Path,
72
+ a_scope: str,
73
+ ) -> None:
74
+ """Build and write separate context files for each path.
75
+
76
+ Args:
77
+ a_builder: Context builder instance.
78
+ a_input_paths: Paths to process individually.
79
+ a_task: Task type.
80
+ a_budget: Token budget.
81
+ a_query: Optional query.
82
+ a_root: Repository root.
83
+ a_output_path: Base output path.
84
+ a_scope: Scope mode string.
85
+ """
86
+ for input_path in a_input_paths:
87
+ if not input_path.exists():
88
+ logger.error("Path does not exist: %s", input_path)
89
+ raise typer.Exit(code=1) from None
90
+ plan = asyncio.run(
91
+ a_builder.build(a_path=input_path, a_task=a_task, a_budget=a_budget, a_query=a_query, a_root=a_root)
92
+ )
93
+ input_name: str = str(input_path.relative_to(a_root)) if input_path != a_root else "."
94
+ plan = ContextPlan(
95
+ chunks=plan.chunks,
96
+ total_tokens=plan.total_tokens,
97
+ total_files=plan.total_files,
98
+ task=plan.task,
99
+ query=plan.query,
100
+ metadata={
101
+ "repository": a_root.name,
102
+ "paths": [input_name],
103
+ "budget": {"max": a_budget.max_tokens},
104
+ "scope": a_scope,
105
+ },
106
+ repository_files=plan.repository_files,
107
+ )
108
+ content_map = asyncio.run(a_builder.load_content(a_plan=plan, a_root=a_root))
109
+ materialized = a_builder.materialize(plan, content_map)
110
+ renderer: MarkdownRenderer = MarkdownRenderer()
111
+ rendered: str = renderer.render(materialized, plan)
112
+ if input_path == a_root:
113
+ sep_output = a_output_path.parent / "root_context.md"
114
+ else:
115
+ rel_name: Path = input_path.relative_to(a_root)
116
+ sep_output = a_output_path.parent / f"{rel_name}_context.md"
117
+ sep_output.parent.mkdir(parents=True, exist_ok=True)
118
+ sep_output.write_text(rendered, encoding="utf-8")
119
+ logger.info("Output: %s", sep_output)
120
+
121
+
122
+ def _run_merged(
123
+ a_builder: ContextBuilder,
124
+ a_input_paths: list[Path],
125
+ a_task: ContextTask,
126
+ a_budget: TokenBudget,
127
+ a_query: str | None,
128
+ a_root: Path,
129
+ a_output_path: Path,
130
+ a_scope: str,
131
+ a_paths_provided: bool,
132
+ ) -> None:
133
+ """Build and write a single merged context file.
134
+
135
+ Args:
136
+ a_builder: Context builder instance.
137
+ a_input_paths: All input paths.
138
+ a_task: Task type.
139
+ a_budget: Token budget.
140
+ a_query: Optional query.
141
+ a_root: Repository root.
142
+ a_output_path: Output file path.
143
+ a_scope: Scope mode string.
144
+ a_paths_provided: Whether user provided explicit paths.
145
+ """
146
+ plan = asyncio.run(
147
+ a_builder.build(
148
+ a_path=a_root,
149
+ a_task=a_task,
150
+ a_budget=a_budget,
151
+ a_query=a_query,
152
+ a_root=a_root,
153
+ a_input_paths=a_input_paths if a_paths_provided else None,
154
+ )
155
+ )
156
+
157
+ input_names = [str(p.relative_to(a_root)) if p != a_root else "." for p in a_input_paths]
158
+ plan = ContextPlan(
159
+ chunks=plan.chunks,
160
+ total_tokens=plan.total_tokens,
161
+ total_files=plan.total_files,
162
+ task=plan.task,
163
+ query=plan.query,
164
+ metadata={
165
+ "repository": a_root.name,
166
+ "paths": input_names,
167
+ "budget": {"max": a_budget.max_tokens},
168
+ "scope": a_scope,
169
+ },
170
+ repository_files=plan.repository_files,
171
+ )
172
+
173
+ content_map = asyncio.run(a_builder.load_content(a_plan=plan, a_root=a_root))
174
+ materialized = a_builder.materialize(plan, content_map)
175
+ renderer_final: MarkdownRenderer = MarkdownRenderer()
176
+ rendered_final: str = renderer_final.render(materialized, plan)
177
+ a_output_path.parent.mkdir(parents=True, exist_ok=True)
178
+ a_output_path.write_text(rendered_final, encoding="utf-8")
179
+
180
+ logger.info(
181
+ "Context generated: %d files, %d tokens, %d chunks",
182
+ plan.total_files,
183
+ plan.total_tokens,
184
+ len(plan.chunks),
185
+ )
186
+ logger.info("Output: %s", a_output_path)
187
+
188
+
189
+ def _run_group(
190
+ a_builder: ContextBuilder,
191
+ a_group_paths: list[Path],
192
+ a_task: ContextTask,
193
+ a_budget: TokenBudget,
194
+ a_query: str | None,
195
+ a_root: Path,
196
+ a_output_path: Path,
197
+ ) -> None:
198
+ """Build and write context for a single group of paths.
199
+
200
+ Args:
201
+ a_builder: Context builder instance.
202
+ a_group_paths: Paths belonging to this group.
203
+ a_task: Task type.
204
+ a_budget: Token budget.
205
+ a_query: Optional query.
206
+ a_root: Repository root.
207
+ a_output_path: Base output path.
208
+ """
209
+ plan = asyncio.run(
210
+ a_builder.build(
211
+ a_path=a_root,
212
+ a_task=a_task,
213
+ a_budget=a_budget,
214
+ a_query=a_query,
215
+ a_root=a_root,
216
+ a_input_paths=a_group_paths,
217
+ )
218
+ )
219
+ group_names: list[str] = [p.name for p in a_group_paths]
220
+ group_label: str = "_".join(group_names) if len(group_names) > 1 else group_names[0]
221
+ group_output = a_output_path.parent / f"{group_label}_context.md"
222
+ group_output.parent.mkdir(parents=True, exist_ok=True)
223
+
224
+ input_names = [str(p.relative_to(a_root)) for p in a_group_paths]
225
+ plan = ContextPlan(
226
+ chunks=plan.chunks,
227
+ total_tokens=plan.total_tokens,
228
+ total_files=plan.total_files,
229
+ task=plan.task,
230
+ query=plan.query,
231
+ metadata={
232
+ "repository": a_root.name,
233
+ "paths": input_names,
234
+ "budget": {"max": a_budget.max_tokens},
235
+ "scope": "group",
236
+ },
237
+ repository_files=plan.repository_files,
238
+ )
239
+
240
+ content_map = asyncio.run(a_builder.load_content(a_plan=plan, a_root=a_root))
241
+ materialized = a_builder.materialize(plan, content_map)
242
+ renderer: MarkdownRenderer = MarkdownRenderer()
243
+ rendered: str = renderer.render(materialized, plan)
244
+ group_output.write_text(rendered, encoding="utf-8")
245
+ logger.info("Output: %s", group_output)
246
+
247
+
248
+ @app.command() # a-prefix-ignore: Typer CLI public names
249
+ def context( # a-prefix-ignore: Typer CLI public names
250
+ task: str = typer.Option(
251
+ "general",
252
+ "--task",
253
+ case_sensitive=False,
254
+ help="Task type: bug_fix, feature, review, onboarding, refactor, document, general",
255
+ ),
256
+ query: str | None = typer.Option(
257
+ None, "--query", "-q", help="Query for relevance matching (reserved, not yet implemented)"
258
+ ),
259
+ output: str = typer.Option("~/.arian/output/context.md", "-o", "--output", help="Output file path"),
260
+ budget: str | None = typer.Option(None, "--budget", help="Maximum tokens for context (default: unlimited)"),
261
+ scope: str = typer.Option("merged", "--scope", help="Scope mode: merged (default) or separate"),
262
+ group: list[str] | None = typer.Option(
263
+ None,
264
+ "--group",
265
+ help="Group paths into one context file. Comma-separated. Repeatable: --group src/,lib/ --group docs/",
266
+ ),
267
+ verbose: bool = typer.Option(False, "-v", "--verbose", help="Enable debug logging"),
268
+ paths: list[str] = typer.Argument(default=None, help="Directories or files to include (default: cwd)"),
269
+ ) -> None:
270
+ """Generate task-aware context from a repository."""
271
+ listener: logging.handlers.QueueListener | None = configure_logging(
272
+ LoggingConfig(level="DEBUG" if verbose else "INFO")
273
+ )
274
+
275
+ try:
276
+ logger.info("Generating context for task=%s", task)
277
+
278
+ try:
279
+ task_enum: ContextTask = ContextTask(task.lower())
280
+ except ValueError:
281
+ msg = f"Invalid task: {task}. Valid tasks: {', '.join(t.value for t in ContextTask)}"
282
+ logger.exception(msg)
283
+ raise typer.Exit(code=1) from None
284
+
285
+ if scope not in ("merged", "separate"):
286
+ msg = f"Invalid scope: {scope}. Valid scopes: merged, separate"
287
+ logger.error(msg)
288
+ raise typer.Exit(code=1) from None
289
+
290
+ token_budget: TokenBudget = TokenBudget(max_tokens=_parse_budget(budget))
291
+ root: Path = Path.cwd()
292
+ output_path: Path = resolve_output_path(output)
293
+
294
+ input_paths: list[Path] = [root / p for p in paths] if paths else [root]
295
+
296
+ classifier: FileClassifier = FileClassifier()
297
+ collector: FileCollector = FileCollector(
298
+ a_extensions=_DEFAULT_EXTENSIONS,
299
+ a_exclude=DEFAULT_EXCLUDES,
300
+ a_classifier=classifier,
301
+ )
302
+ index: MemoryRepositoryIndex = MemoryRepositoryIndex()
303
+ analyzer: PythonAnalyzer = PythonAnalyzer()
304
+ planner: ContextPlanner = ContextPlanner(a_classifier=classifier)
305
+ materializer: ContextMaterializer = ContextMaterializer(a_analyzer=analyzer)
306
+ builder: ContextBuilder = ContextBuilder(
307
+ a_collector=collector,
308
+ a_index=index,
309
+ a_planner=planner,
310
+ a_materializer=materializer,
311
+ )
312
+
313
+ if group:
314
+ if scope != "merged":
315
+ logger.warning("--scope is ignored when --group is used")
316
+ for group_spec in group:
317
+ group_paths: list[Path] = [root / p.strip() for p in group_spec.split(",")]
318
+ for gp in group_paths:
319
+ if not gp.exists():
320
+ logger.error("Path does not exist: %s", gp)
321
+ raise typer.Exit(code=1) from None
322
+ _run_group(builder, group_paths, task_enum, token_budget, query, root, output_path)
323
+
324
+ elif scope == "separate":
325
+ _run_separate(builder, input_paths, task_enum, token_budget, query, root, output_path, scope)
326
+ else:
327
+ _run_merged(builder, input_paths, task_enum, token_budget, query, root, output_path, scope, bool(paths))
328
+ finally:
329
+ if listener is not None:
330
+ listener.stop()
@@ -0,0 +1,51 @@
1
+ """Domain layer for Arian.
2
+
3
+ Provides domain entities, enums, and exceptions.
4
+ Zero external dependencies — only stdlib imports.
5
+ """
6
+
7
+ from arian.domain.context import ContextChunk
8
+ from arian.domain.context import ContextPlan
9
+ from arian.domain.context import ContextResult
10
+ from arian.domain.context import ContextTask
11
+ from arian.domain.context import PlannedFile
12
+ from arian.domain.exceptions import ContextBuilderError
13
+ from arian.domain.exceptions import InputNotFoundError
14
+ from arian.domain.exceptions import NoDocumentsError
15
+ from arian.domain.exceptions import ProjectBaseError
16
+ from arian.domain.protocols import LanguageAnalyzerProtocol
17
+ from arian.domain.repository import Dependency
18
+ from arian.domain.repository import FileContent
19
+ from arian.domain.repository import Module
20
+ from arian.domain.repository import Repository
21
+ from arian.domain.repository import RepositoryFile
22
+ from arian.domain.repository import Symbol
23
+ from arian.domain.shared import CompressionLevel
24
+ from arian.domain.shared import DependencyKind
25
+ from arian.domain.shared import FileRole
26
+ from arian.domain.shared import SymbolKind
27
+ from arian.domain.shared import TokenBudget
28
+
29
+ __all__ = [
30
+ "CompressionLevel",
31
+ "ContextBuilderError",
32
+ "ContextChunk",
33
+ "ContextPlan",
34
+ "ContextResult",
35
+ "ContextTask",
36
+ "Dependency",
37
+ "DependencyKind",
38
+ "FileContent",
39
+ "FileRole",
40
+ "InputNotFoundError",
41
+ "LanguageAnalyzerProtocol",
42
+ "Module",
43
+ "NoDocumentsError",
44
+ "PlannedFile",
45
+ "ProjectBaseError",
46
+ "Repository",
47
+ "RepositoryFile",
48
+ "Symbol",
49
+ "SymbolKind",
50
+ "TokenBudget",
51
+ ]