compass-code 0.1.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 (137) hide show
  1. compass_cli/__init__.py +0 -0
  2. compass_cli/commands/__init__.py +0 -0
  3. compass_cli/commands/ingest.py +310 -0
  4. compass_cli/commands/mcp.py +25 -0
  5. compass_cli/commands/query.py +181 -0
  6. compass_cli/commands/serve.py +123 -0
  7. compass_cli/commands/setup.py +171 -0
  8. compass_cli/display.py +231 -0
  9. compass_cli/errors.py +30 -0
  10. compass_cli/main.py +54 -0
  11. compass_cli/progress.py +191 -0
  12. compass_cli/result_views.py +389 -0
  13. compass_cli/types.py +41 -0
  14. compass_code-0.1.0.dist-info/METADATA +72 -0
  15. compass_code-0.1.0.dist-info/RECORD +137 -0
  16. compass_code-0.1.0.dist-info/WHEEL +4 -0
  17. compass_code-0.1.0.dist-info/entry_points.txt +2 -0
  18. compass_core/__init__.py +1 -0
  19. compass_core/contract/__init__.py +6 -0
  20. compass_core/contract/errors.py +116 -0
  21. compass_core/contract/models.py +204 -0
  22. compass_core/contract/params.py +99 -0
  23. compass_core/contract/results.py +370 -0
  24. compass_core/contract/serialize.py +59 -0
  25. compass_core/contract/types.py +76 -0
  26. compass_core/errors.py +148 -0
  27. compass_core/graph/__init__.py +0 -0
  28. compass_core/graph/db.py +205 -0
  29. compass_core/graph/schema.py +165 -0
  30. compass_core/graph/stored_ids.py +133 -0
  31. compass_core/graph/writer.py +482 -0
  32. compass_core/index/__init__.py +1 -0
  33. compass_core/index/freshness.py +55 -0
  34. compass_core/index/locate.py +155 -0
  35. compass_core/index/metadata.py +107 -0
  36. compass_core/ir/__init__.py +0 -0
  37. compass_core/ir/graph.py +65 -0
  38. compass_core/ir/models.py +75 -0
  39. compass_core/languages/__init__.py +1 -0
  40. compass_core/languages/base.py +26 -0
  41. compass_core/languages/python/__init__.py +6 -0
  42. compass_core/languages/python/extractor.py +670 -0
  43. compass_core/languages/python/import_resolver.py +107 -0
  44. compass_core/languages/registry.py +89 -0
  45. compass_core/logger.py +42 -0
  46. compass_core/phases/__init__.py +0 -0
  47. compass_core/phases/base.py +58 -0
  48. compass_core/phases/discovery.py +167 -0
  49. compass_core/phases/parse.py +80 -0
  50. compass_core/phases/persistence.py +33 -0
  51. compass_core/phases/pipeline.py +152 -0
  52. compass_core/phases/resolution.py +572 -0
  53. compass_core/phases/symbol_table.py +36 -0
  54. compass_core/phases/validation.py +76 -0
  55. compass_core/progress.py +45 -0
  56. compass_core/py.typed +0 -0
  57. compass_core/query/__init__.py +1 -0
  58. compass_core/query/cypher.py +92 -0
  59. compass_core/query/detail.py +241 -0
  60. compass_core/query/graph_map.py +127 -0
  61. compass_core/query/impact.py +273 -0
  62. compass_core/query/impact_walk.py +352 -0
  63. compass_core/query/overview.py +221 -0
  64. compass_core/query/references.py +235 -0
  65. compass_core/query/rows.py +378 -0
  66. compass_core/query/search.py +183 -0
  67. compass_core/query/status.py +49 -0
  68. compass_core/query/walk_queries.py +202 -0
  69. compass_core/render.py +408 -0
  70. compass_core/settings.py +61 -0
  71. compass_core/shared_types.py +78 -0
  72. compass_mcp/__init__.py +0 -0
  73. compass_mcp/guide.py +45 -0
  74. compass_mcp/py.typed +0 -0
  75. compass_mcp/server.py +261 -0
  76. compass_server/__init__.py +5 -0
  77. compass_server/errors.py +84 -0
  78. compass_server/main.py +42 -0
  79. compass_server/py.typed +0 -0
  80. compass_server/routers/__init__.py +0 -0
  81. compass_server/routers/query.py +182 -0
  82. compass_server/settings.py +37 -0
  83. compass_server/static/404.html +1 -0
  84. compass_server/static/_next/static/chunks/1629039a.eed2a06658ec4f9c.js +1 -0
  85. compass_server/static/_next/static/chunks/1ff282bf-bb7c483245264fc9.js +1 -0
  86. compass_server/static/_next/static/chunks/223.f881a22f446259bf.js +508 -0
  87. compass_server/static/_next/static/chunks/51fa5c19.6396973222c9d6fd.js +485 -0
  88. compass_server/static/_next/static/chunks/556.443c1682b6b20128.js +1 -0
  89. compass_server/static/_next/static/chunks/684-1aa0170f59baf77e.js +1 -0
  90. compass_server/static/_next/static/chunks/92e71660.3a42d4f70d017951.js +342 -0
  91. compass_server/static/_next/static/chunks/937-8692224bb6d1b605.js +1 -0
  92. compass_server/static/_next/static/chunks/app/_not-found/page-2c96ddf295eb6a86.js +1 -0
  93. compass_server/static/_next/static/chunks/app/layout-4bd0ba4a7d18aff9.js +1 -0
  94. compass_server/static/_next/static/chunks/app/page-5376a0a98bbd1faa.js +1 -0
  95. compass_server/static/_next/static/chunks/framework-96dcd958cfe08bd4.js +1 -0
  96. compass_server/static/_next/static/chunks/main-6dee8e626b16731a.js +1 -0
  97. compass_server/static/_next/static/chunks/main-app-460e8bc670818718.js +1 -0
  98. compass_server/static/_next/static/chunks/pages/_app-638a3e7363f92b80.js +1 -0
  99. compass_server/static/_next/static/chunks/pages/_error-6a72dbbb45cae743.js +1 -0
  100. compass_server/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  101. compass_server/static/_next/static/chunks/webpack-597057bf48ce4111.js +1 -0
  102. compass_server/static/_next/static/css/1b0fbfd8dd653f37.css +3 -0
  103. compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_buildManifest.js +1 -0
  104. compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_ssgManifest.js +1 -0
  105. compass_server/static/_next/static/media/0086f8992871c45b-s.woff2 +0 -0
  106. compass_server/static/_next/static/media/034d78ad42e9620c-s.woff2 +0 -0
  107. compass_server/static/_next/static/media/03b436aa846269de-s.woff2 +0 -0
  108. compass_server/static/_next/static/media/0484562807a97172-s.p.woff2 +0 -0
  109. compass_server/static/_next/static/media/08aedeceaf1dcd57-s.woff2 +0 -0
  110. compass_server/static/_next/static/media/0aa834ed78bf6d07-s.woff2 +0 -0
  111. compass_server/static/_next/static/media/29a4aea02fdee119-s.woff2 +0 -0
  112. compass_server/static/_next/static/media/29e7bbdce9332268-s.woff2 +0 -0
  113. compass_server/static/_next/static/media/36008747766f78c6-s.woff2 +0 -0
  114. compass_server/static/_next/static/media/39969fcf98a3026e-s.woff2 +0 -0
  115. compass_server/static/_next/static/media/4de1fea1a954a5b6-s.p.woff2 +0 -0
  116. compass_server/static/_next/static/media/5c0c2bcbaa4149ca-s.p.woff2 +0 -0
  117. compass_server/static/_next/static/media/67957d42bae0796d-s.woff2 +0 -0
  118. compass_server/static/_next/static/media/6d38f9d4e0f4772b-s.woff2 +0 -0
  119. compass_server/static/_next/static/media/6d664cce900333ee-s.p.woff2 +0 -0
  120. compass_server/static/_next/static/media/886030b0b59bc5a7-s.woff2 +0 -0
  121. compass_server/static/_next/static/media/8888a3826f4a3af4-s.p.woff2 +0 -0
  122. compass_server/static/_next/static/media/939c4f875ee75fbb-s.woff2 +0 -0
  123. compass_server/static/_next/static/media/a1386beebedccca4-s.woff2 +0 -0
  124. compass_server/static/_next/static/media/ab00a911ac2adf48-s.woff2 +0 -0
  125. compass_server/static/_next/static/media/b957ea75a84b6ea7-s.p.woff2 +0 -0
  126. compass_server/static/_next/static/media/bb3ef058b751a6ad-s.p.woff2 +0 -0
  127. compass_server/static/_next/static/media/c3bc380753a8436c-s.woff2 +0 -0
  128. compass_server/static/_next/static/media/cce080f35d014443-s.woff2 +0 -0
  129. compass_server/static/_next/static/media/db911767852bc875-s.woff2 +0 -0
  130. compass_server/static/_next/static/media/eafabf029ad39a43-s.p.woff2 +0 -0
  131. compass_server/static/_next/static/media/f10b8e9d91f3edcb-s.woff2 +0 -0
  132. compass_server/static/_next/static/media/f911b923c6adde36-s.woff2 +0 -0
  133. compass_server/static/_next/static/media/fe0777f1195381cb-s.woff2 +0 -0
  134. compass_server/static/index.html +1 -0
  135. compass_server/static/index.txt +25 -0
  136. compass_server/web_bundle.py +27 -0
  137. compass_server/web_limits.py +48 -0
@@ -0,0 +1,107 @@
1
+ """Python import resolution: relative imports, packages, __init__.py.
2
+
3
+ Moved from resolver/import_map.py, adapted to the ImportResolver Protocol
4
+ and IR types. Relative imports (`.user`, `..utils`) are resolved against the
5
+ importing file's own package, per Python's relative-import level rules:
6
+ one leading dot means "this file's package", each additional dot walks up
7
+ one more parent package. A package's `__init__.py` *is* its package, so its
8
+ own leading-dot imports resolve relative to its own module_name rather than
9
+ a parent of it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from compass_core.ir.models import ParsedFile
15
+ from compass_core.shared_types import Language
16
+
17
+
18
+ class PythonImportResolver:
19
+ language_id = Language.PYTHON
20
+
21
+ def resolve(self, files: list[ParsedFile]) -> dict[str, dict[str, str | None]]:
22
+ module_to_path = _module_index(files)
23
+ return {pf.path: _resolve_file_imports(pf, module_to_path) for pf in files}
24
+
25
+
26
+ def _module_index(files: list[ParsedFile]) -> dict[str, str]:
27
+ """Build a mapping of module names to their file paths."""
28
+ return {pf.module_name: pf.path for pf in files}
29
+
30
+
31
+ def _resolve_file_imports(pf: ParsedFile, module_to_path: dict[str, str]) -> dict[str, str | None]:
32
+ """Resolve all imports in a single file to their source file paths."""
33
+ mapping: dict[str, str | None] = {}
34
+ for imp in pf.imports:
35
+ resolved = _resolve_module(imp.module, pf, module_to_path)
36
+ if imp.names:
37
+ # `from X import a, b, *` — each name gets its own entry
38
+ for name in imp.names:
39
+ local = imp.alias if (len(imp.names) == 1 and imp.alias) else name
40
+ target = resolved
41
+ if imp.is_relative and not imp.module.lstrip("."):
42
+ # `from . import name` / `from .. import name` — each
43
+ # imported name is usually a submodule of the base
44
+ # package (try that first); fall back to the generic
45
+ # resolution (the base package's own file) for names
46
+ # that are attributes defined in its __init__.py itself.
47
+ target = (
48
+ _resolve_bare_relative(imp.module, name, pf, module_to_path) or resolved
49
+ )
50
+ mapping[local] = target
51
+ else:
52
+ # `import X` or `import X as Y`
53
+ local = imp.alias or imp.module.split(".")[0]
54
+ mapping[local] = resolved
55
+ return mapping
56
+
57
+
58
+ def _resolve_module(
59
+ module: str, importer: ParsedFile, module_to_path: dict[str, str]
60
+ ) -> str | None:
61
+ """Resolve a dotted module name to an absolute file path.
62
+
63
+ Returns the file path if the module is internal to the repo, or None
64
+ if it is an external dependency.
65
+ """
66
+ if not module.startswith("."):
67
+ return module_to_path.get(module)
68
+
69
+ base_parts = _base_package_parts(module, importer)
70
+ remainder = module.lstrip(".")
71
+ target_parts = base_parts + remainder.split(".") if remainder else base_parts
72
+ candidate = ".".join(part for part in target_parts if part)
73
+ return module_to_path.get(candidate)
74
+
75
+
76
+ def _base_package_parts(module: str, importer: ParsedFile) -> list[str]:
77
+ """Compute the dotted package an import's leading dots are relative to.
78
+
79
+ One dot means "the importing file's own package". A package's
80
+ `__init__.py` *is* that package, so its own module_name is the base;
81
+ any other file's base is its module_name with the last (leaf module)
82
+ segment stripped. Each dot beyond the first walks up one more level.
83
+ """
84
+ level = len(module) - len(module.lstrip("."))
85
+ is_package_init = importer.path.endswith("__init__.py") or importer.path.endswith(
86
+ "__init__.pyi"
87
+ )
88
+ parts = importer.module_name.split(".") if importer.module_name else []
89
+ if not is_package_init:
90
+ parts = parts[:-1]
91
+ climb = level - 1
92
+ if climb:
93
+ parts = parts[:-climb] if climb <= len(parts) else []
94
+ return parts
95
+
96
+
97
+ def _resolve_bare_relative(
98
+ module: str, name: str, importer: ParsedFile, module_to_path: dict[str, str]
99
+ ) -> str | None:
100
+ """Resolve one name from `from . import name` / `from .. import name`.
101
+
102
+ Each name here is a submodule of the base package rather than an
103
+ attribute of a single target module, so it must be resolved per-name.
104
+ """
105
+ base_parts = _base_package_parts(module, importer)
106
+ candidate = ".".join([*base_parts, name])
107
+ return module_to_path.get(candidate)
@@ -0,0 +1,89 @@
1
+ """Extension/language-id -> plugin lookup. No phase past Phase 1 may know a
2
+ specific language — this module is the only place that maps a file
3
+ extension to a concrete LanguageExtractor."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from compass_core.errors import UnsupportedLanguageError
8
+ from compass_core.languages.base import ImportResolver, LanguageExtractor
9
+ from compass_core.shared_types import Language
10
+
11
+ _extractors_by_extension: dict[str, LanguageExtractor] = {}
12
+ _extractors_by_language: dict[Language, LanguageExtractor] = {}
13
+ _import_resolvers_by_language: dict[Language, ImportResolver] = {}
14
+
15
+
16
+ def register_extractor(extractor: LanguageExtractor) -> None:
17
+ for ext in extractor.file_extensions:
18
+ _extractors_by_extension[ext] = extractor
19
+ _extractors_by_language[extractor.language_id] = extractor
20
+
21
+
22
+ def register_import_resolver(resolver: ImportResolver) -> None:
23
+ _import_resolvers_by_language[resolver.language_id] = resolver
24
+
25
+
26
+ def get_extractor_for_extension(ext: str) -> LanguageExtractor:
27
+ try:
28
+ return _extractors_by_extension[ext]
29
+ except KeyError:
30
+ raise UnsupportedLanguageError(
31
+ f"no extractor registered for extension {ext!r}", language=ext
32
+ ) from None
33
+
34
+
35
+ def get_extractor_for_language(language_id: Language) -> LanguageExtractor:
36
+ try:
37
+ return _extractors_by_language[language_id]
38
+ except KeyError:
39
+ raise UnsupportedLanguageError(
40
+ f"no extractor registered for language {language_id!r}", language=language_id
41
+ ) from None
42
+
43
+
44
+ def get_import_resolver(language_id: Language) -> ImportResolver:
45
+ try:
46
+ return _import_resolvers_by_language[language_id]
47
+ except KeyError:
48
+ raise UnsupportedLanguageError(
49
+ f"no import resolver registered for language {language_id!r}", language=language_id
50
+ ) from None
51
+
52
+
53
+ def registered_extensions() -> set[str]:
54
+ return set(_extractors_by_extension.keys())
55
+
56
+
57
+ def _snapshot_for_tests() -> tuple[
58
+ dict[str, LanguageExtractor], dict[Language, LanguageExtractor], dict[Language, ImportResolver]
59
+ ]:
60
+ """Test-only: capture current registry state so it can be restored after a test wipes it."""
61
+ return (
62
+ dict(_extractors_by_extension),
63
+ dict(_extractors_by_language),
64
+ dict(_import_resolvers_by_language),
65
+ )
66
+
67
+
68
+ def _restore_for_tests(
69
+ snapshot: tuple[
70
+ dict[str, LanguageExtractor],
71
+ dict[Language, LanguageExtractor],
72
+ dict[Language, ImportResolver],
73
+ ],
74
+ ) -> None:
75
+ """Test-only: restore registry state captured by _snapshot_for_tests."""
76
+ by_ext, by_lang, resolvers = snapshot
77
+ _extractors_by_extension.clear()
78
+ _extractors_by_extension.update(by_ext)
79
+ _extractors_by_language.clear()
80
+ _extractors_by_language.update(by_lang)
81
+ _import_resolvers_by_language.clear()
82
+ _import_resolvers_by_language.update(resolvers)
83
+
84
+
85
+ def _reset_for_tests() -> None:
86
+ """Test-only: clear registry state between tests. Not part of the public API."""
87
+ _extractors_by_extension.clear()
88
+ _extractors_by_language.clear()
89
+ _import_resolvers_by_language.clear()
compass_core/logger.py ADDED
@@ -0,0 +1,42 @@
1
+ """Centralized logging configuration for Code Compass.
2
+
3
+ Call :func:`setup` once at process startup (e.g. from the CLI entry point).
4
+ All other modules should continue to use ``logging.getLogger(__name__)`` —
5
+ this module only configures the root handler.
6
+
7
+ Example::
8
+
9
+ from compass_core.log import setup
10
+ setup(level="INFO")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+
17
+ from rich.logging import RichHandler
18
+
19
+
20
+ def setup(level: str = "WARNING", *, show_path: bool = False) -> None:
21
+ """Configure the root logger with a Rich console handler.
22
+
23
+ Args:
24
+ level: Log level string (e.g. ``"DEBUG"``, ``"INFO"``, ``"WARNING"``).
25
+ Defaults to ``"WARNING"`` so only important messages surface in the
26
+ CLI by default.
27
+ show_path: Whether Rich should append the source file/line to each
28
+ log line. Useful in debug mode, noisy otherwise.
29
+ """
30
+ handler = RichHandler(
31
+ rich_tracebacks=True,
32
+ markup=True,
33
+ show_path=show_path,
34
+ log_time_format="[%X]",
35
+ )
36
+ logging.basicConfig(
37
+ level=level.upper(),
38
+ format="%(message)s",
39
+ datefmt="[%X]",
40
+ handlers=[handler],
41
+ force=True,
42
+ )
File without changes
@@ -0,0 +1,58 @@
1
+ """The Phase Protocol and IngestContext that flows through phases 0-5.
2
+ Enables `compass ingest --stop-after <phase>` for debugging and lets any
3
+ phase be unit-tested with a hand-built ctx, no upstream phase required."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Protocol, runtime_checkable
10
+
11
+ from compass_core.graph.writer import WriteResult
12
+ from compass_core.ir.graph import ResolvedGraph
13
+ from compass_core.ir.models import ParsedFile
14
+ from compass_core.progress import ProgressReporter
15
+ from compass_core.shared_types import Language
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class FileMetadata:
20
+ path: Path
21
+ language: Language
22
+ content_hash: str
23
+
24
+
25
+ @dataclass
26
+ class FileManifest:
27
+ files: list[FileMetadata]
28
+ dirs_scanned: int = 0
29
+ ignored_count: int = 0
30
+
31
+
32
+ @dataclass
33
+ class IngestReport:
34
+ resolved_by_language: dict[Language, float]
35
+ unresolved_by_language: dict[Language, float]
36
+ dangling_import_count: int
37
+ orphan_node_count: int
38
+ degraded_file_count: int
39
+
40
+
41
+ @dataclass
42
+ class IngestContext:
43
+ repo_root: Path
44
+ manifest: FileManifest | None = None
45
+ parsed_files: list[ParsedFile] | None = None
46
+ symbol_table: dict[str, str] | None = None
47
+ import_map: dict[str, dict[str, str | None]] | None = None
48
+ resolved_graph: ResolvedGraph | None = None
49
+ report: IngestReport | None = None
50
+ reporter: ProgressReporter | None = None
51
+ write_result: WriteResult | None = None
52
+
53
+
54
+ @runtime_checkable
55
+ class Phase(Protocol):
56
+ name: str
57
+
58
+ def run(self, ctx: IngestContext) -> IngestContext: ...
@@ -0,0 +1,167 @@
1
+ """Phase 0: repo path -> FileManifest. Walks the repo respecting
2
+ .gitignore/.compassignore, detects language per file via the registry's
3
+ known extensions, filters binaries/vendored/generated code, and computes a
4
+ content_hash per file (unused for skip-logic yet — captured now for future
5
+ incremental re-index, per spec)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import logging
11
+ from collections.abc import Iterator
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import pathspec
16
+
17
+ from compass_core.errors import DiscoveryError
18
+ from compass_core.languages import registry
19
+ from compass_core.phases.base import FileManifest, FileMetadata, IngestContext
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _SKIP_DIRS = frozenset(
24
+ {
25
+ ".git",
26
+ ".claude",
27
+ "__pycache__",
28
+ "node_modules",
29
+ ".venv",
30
+ "venv",
31
+ "env",
32
+ "dist",
33
+ "build",
34
+ ".tox",
35
+ ".mypy_cache",
36
+ ".ruff_cache",
37
+ ".pytest_cache",
38
+ ".eggs",
39
+ "*.egg-info",
40
+ }
41
+ )
42
+ _MAX_BYTES = 500 * 1024
43
+
44
+
45
+ @dataclass
46
+ class _WalkStats:
47
+ """Counts skipped directories/files as a byproduct of the walk, so the
48
+ manifest can report how much of the repo was filtered out without a
49
+ second traversal."""
50
+
51
+ dirs_scanned: int = 0
52
+ ignored_count: int = 0
53
+
54
+
55
+ class DiscoveryPhase:
56
+ name = "discovery"
57
+
58
+ def run(self, ctx: IngestContext) -> IngestContext:
59
+ root = ctx.repo_root.resolve()
60
+ if not root.is_dir():
61
+ raise DiscoveryError(f"not a directory: {root}", phase_name=self.name)
62
+
63
+ spec = _load_ignore_spec(root)
64
+ extensions = registry.registered_extensions()
65
+ stats = _WalkStats()
66
+ files: list[FileMetadata] = []
67
+ for path in _walk(root, root, spec, extensions, stats):
68
+ language = registry.get_extractor_for_extension(path.suffix).language_id
69
+ content_hash = hashlib.sha256(path.read_bytes()).hexdigest()
70
+ files.append(FileMetadata(path=path, language=language, content_hash=content_hash))
71
+
72
+ ctx.manifest = FileManifest(
73
+ files=files, dirs_scanned=stats.dirs_scanned, ignored_count=stats.ignored_count
74
+ )
75
+ ctx.repo_root = root
76
+ if ctx.reporter is not None:
77
+ ctx.reporter.update("discovery", len(files), len(files))
78
+ return ctx
79
+
80
+
81
+ def _load_ignore_spec(root: Path) -> pathspec.PathSpec | None:
82
+ """Load and merge .gitignore and .compassignore pattern sets from *root*.
83
+
84
+ Moved from parser/file_walker.py::_load_gitignore, extended to also
85
+ consider a .compassignore file (if present), merging both pattern sets
86
+ into a single PathSpec.
87
+ """
88
+ lines: list[str] = []
89
+ for name in (".gitignore", ".compassignore"):
90
+ ignore_file = root / name
91
+ if not ignore_file.is_file():
92
+ continue
93
+ try:
94
+ lines.extend(ignore_file.read_text(encoding="utf-8", errors="replace").splitlines())
95
+ except OSError as exc:
96
+ logger.warning("Could not read %s: %s", name, exc)
97
+
98
+ if not lines:
99
+ return None
100
+ return pathspec.PathSpec.from_lines("gitwildmatch", lines)
101
+
102
+
103
+ def _walk(
104
+ current: Path,
105
+ root: Path,
106
+ spec: pathspec.PathSpec | None,
107
+ extensions: set[str],
108
+ stats: _WalkStats,
109
+ ) -> Iterator[Path]:
110
+ """Recursively traverse directories to find files with a registered extension.
111
+
112
+ Generalized from parser/file_walker.py::_recurse — same PermissionError
113
+ isolation (catch, log at debug, skip) and _should_skip_dir/_should_skip_file
114
+ logic, but filters on `entry.suffix in extensions` instead of `== ".py"`.
115
+ """
116
+ try:
117
+ entries = sorted(current.iterdir(), key=lambda p: (p.is_file(), p.name))
118
+ except PermissionError as exc:
119
+ logger.debug("Permission denied, skipping: %s (%s)", current, exc)
120
+ return
121
+
122
+ stats.dirs_scanned += 1
123
+ for entry in entries:
124
+ if entry.is_dir():
125
+ if _should_skip_dir(entry, root, spec):
126
+ stats.ignored_count += 1
127
+ continue
128
+ yield from _walk(entry, root, spec, extensions, stats)
129
+
130
+ elif entry.is_file() and entry.suffix in extensions:
131
+ if _should_skip_file(entry, root, spec):
132
+ stats.ignored_count += 1
133
+ continue
134
+ yield entry
135
+
136
+
137
+ def _should_skip_dir(entry: Path, root: Path, spec: pathspec.PathSpec | None) -> bool:
138
+ """Determine if a directory should be skipped during traversal.
139
+
140
+ Checks against common skip directories and .gitignore/.compassignore rules.
141
+ """
142
+ if entry.name in _SKIP_DIRS:
143
+ return True
144
+ if spec is not None:
145
+ # pathspec expects forward-slash paths; append "/" for directory matching
146
+ rel = entry.relative_to(root).as_posix() + "/"
147
+ if spec.match_file(rel):
148
+ return True
149
+ return False
150
+
151
+
152
+ def _should_skip_file(entry: Path, root: Path, spec: pathspec.PathSpec | None) -> bool:
153
+ """Determine if a file should be skipped during discovery.
154
+
155
+ Checks against .gitignore/.compassignore rules and file size limits.
156
+ """
157
+ if spec is not None:
158
+ rel = entry.relative_to(root).as_posix()
159
+ if spec.match_file(rel):
160
+ return True
161
+ try:
162
+ if entry.stat().st_size > _MAX_BYTES:
163
+ logger.debug("Skipping large file (%d bytes): %s", entry.stat().st_size, entry)
164
+ return True
165
+ except OSError:
166
+ return True
167
+ return False
@@ -0,0 +1,80 @@
1
+ """Phase 1: FileManifest -> list[ParsedFile]. tree-sitter parse per file,
2
+ dispatched via the language registry, run in ProcessPoolExecutor. Per-file
3
+ errors isolate (degraded flag on ParsedFile), never kill the pool — this is
4
+ the single most important error-isolation point in the pipeline."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import traceback
10
+ from concurrent.futures import ProcessPoolExecutor, as_completed
11
+ from functools import cache
12
+ from pathlib import Path
13
+
14
+ from compass_core.ir.models import ParsedFile
15
+ from compass_core.languages import registry
16
+ from compass_core.languages.base import LanguageExtractor
17
+ from compass_core.phases.base import IngestContext
18
+ from compass_core.shared_types import Language
19
+
20
+
21
+ @cache
22
+ def _get_extractor(language_id: Language) -> LanguageExtractor:
23
+ return registry.get_extractor_for_language(language_id)
24
+
25
+
26
+ def _parse_one(language_id: Language, abs_path: str, rel_path: str) -> ParsedFile:
27
+ """rel_path is abs_path made relative to ctx.repo_root by the caller.
28
+ Extractors receive the relative path (per the languages/python module-name
29
+ contract established in Task 5: file_path is repo-relative, not absolute,
30
+ so module-name derivation can build a dotted name without needing root
31
+ itself) — abs_path is only used here, to actually read the file's bytes."""
32
+ extractor = _get_extractor(language_id)
33
+ with open(abs_path, "rb") as f:
34
+ source = f.read()
35
+ return extractor.parse(source, Path(rel_path))
36
+
37
+
38
+ def _parse_worker(args: tuple[Language, str, str]) -> ParsedFile:
39
+ language_id, abs_path, rel_path = args
40
+ try:
41
+ return _parse_one(language_id, abs_path, rel_path)
42
+ except Exception as exc: # noqa: BLE001 — per-file isolation boundary, see module docstring
43
+ traceback.print_exc()
44
+ return ParsedFile(
45
+ path=rel_path,
46
+ language=language_id,
47
+ module_name="",
48
+ content_hash="",
49
+ definitions=[],
50
+ imports=[],
51
+ call_sites=[],
52
+ degraded=True,
53
+ error=str(exc),
54
+ )
55
+
56
+
57
+ class ParsePhase:
58
+ name = "parse"
59
+
60
+ def __init__(self, max_workers: int | None = None) -> None:
61
+ self._max_workers = max_workers
62
+
63
+ def run(self, ctx: IngestContext) -> IngestContext:
64
+ assert ctx.manifest is not None, "ParsePhase requires DiscoveryPhase to run first"
65
+ tasks = [
66
+ (f.language, str(f.path), str(f.path.relative_to(ctx.repo_root)))
67
+ for f in ctx.manifest.files
68
+ ]
69
+ workers = self._max_workers or os.cpu_count() or 1
70
+
71
+ parsed: list[ParsedFile] = []
72
+ with ProcessPoolExecutor(max_workers=workers) as pool:
73
+ futures = {pool.submit(_parse_worker, task): task for task in tasks}
74
+ for done, future in enumerate(as_completed(futures), start=1):
75
+ parsed.append(future.result())
76
+ if ctx.reporter is not None:
77
+ ctx.reporter.update("parse", done, len(tasks))
78
+
79
+ ctx.parsed_files = parsed
80
+ return ctx
@@ -0,0 +1,33 @@
1
+ """Phase 5: ResolvedGraph -> LadybugDB. Nodes before edges, per
2
+ graph/writer.py's write order (File -> Class -> Function -> ExternalFunction
3
+ nodes, then CONTAINS -> IMPORTS -> INHERITS -> CALLS -> CALLS_EXTERN edges).
4
+
5
+ Deliberately deviates from the uniform Phase Protocol: `run` takes an extra
6
+ `db: GraphDB` argument because the DB connection is a context-managed
7
+ resource owned by phases/pipeline.py, not something this phase constructs
8
+ itself. pipeline.py calls this phase specially, not through the generic
9
+ phase loop."""
10
+
11
+ from __future__ import annotations
12
+
13
+ from compass_core.errors import PersistenceError
14
+ from compass_core.graph.db import GraphDB
15
+ from compass_core.graph.writer import write
16
+ from compass_core.phases.base import IngestContext
17
+
18
+
19
+ class PersistencePhase:
20
+ name = "persistence"
21
+
22
+ def run(self, ctx: IngestContext, db: GraphDB) -> IngestContext:
23
+ assert ctx.resolved_graph is not None
24
+ try:
25
+ ctx.write_result = write(db, ctx.resolved_graph, reporter=ctx.reporter)
26
+ except PersistenceError:
27
+ raise
28
+ except Exception as exc: # noqa: BLE001 — unrecoverable DB failure boundary
29
+ raise PersistenceError(
30
+ f"failed to persist resolved graph: {exc}",
31
+ phase_name=self.name,
32
+ ) from exc
33
+ return ctx