traceforge-toolkit 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 (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,239 @@
1
+ """Config loading with hierarchical precedence.
2
+
3
+ Precedence (highest → lowest):
4
+ 1. Explicit kwargs passed to load_config()
5
+ 2. TRACEFORGE_* environment variables
6
+ 3. TRACEFORGE_CONFIG env var (explicit path override)
7
+ 4. Project-local: ./traceforge.yaml
8
+ 5. User-global: ~/.traceforge/config.yaml
9
+ 6. Built-in defaults
10
+
11
+ On first config access, ~/.traceforge/ is auto-created with a default config
12
+ if it does not already exist. This is the post-install bootstrap — no separate
13
+ init command needed.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import yaml
24
+
25
+ from traceforge.config.defaults import DEFAULT_CONFIG_YAML
26
+ from traceforge.config.models import TraceforgeConfig
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Module-level singleton (lazily loaded)
31
+ _config: TraceforgeConfig | None = None
32
+ _bootstrapped: bool = False
33
+
34
+ # Well-known paths
35
+ _USER_CONFIG_DIR = Path.home() / ".traceforge"
36
+ _USER_MAPPINGS_DIR = _USER_CONFIG_DIR / "mappings"
37
+ _USER_CONFIG_FILE = _USER_CONFIG_DIR / "config.yaml"
38
+ _PROJECT_CONFIG_FILE = Path("traceforge.yaml")
39
+
40
+
41
+ def _ensure_user_dir() -> None:
42
+ """Create ~/.traceforge/ with default config if it doesn't exist.
43
+
44
+ Called once on first config access. Idempotent — safe to call multiple times.
45
+ This is the post-install bootstrap: no separate `traceforge init` needed.
46
+ """
47
+ global _bootstrapped
48
+ if _bootstrapped:
49
+ return
50
+ _bootstrapped = True
51
+
52
+ try:
53
+ _USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
54
+ _USER_MAPPINGS_DIR.mkdir(parents=True, exist_ok=True)
55
+
56
+ if not _USER_CONFIG_FILE.exists():
57
+ _USER_CONFIG_FILE.write_text(DEFAULT_CONFIG_YAML, encoding="utf-8")
58
+ logger.info("Created default config at %s", _USER_CONFIG_FILE)
59
+ except OSError as exc:
60
+ # Non-fatal: read-only filesystem, containerized env, etc.
61
+ logger.debug("Could not create ~/.traceforge/: %s", exc)
62
+
63
+
64
+ def _find_config_files() -> list[Path]:
65
+ """Discover config files in precedence order (first = highest priority).
66
+
67
+ Returns paths that exist on disk. Higher-priority files override lower.
68
+ """
69
+ files: list[Path] = []
70
+
71
+ # Explicit override via env var
72
+ env_path = os.environ.get("TRACEFORGE_CONFIG")
73
+ if env_path:
74
+ p = Path(env_path).expanduser().resolve()
75
+ if p.is_file():
76
+ files.append(p)
77
+ return files # explicit override skips discovery
78
+ else:
79
+ logger.warning("TRACEFORGE_CONFIG=%s does not exist, falling back to discovery", p)
80
+
81
+ # Project-local
82
+ project_file = _PROJECT_CONFIG_FILE.resolve()
83
+ if project_file.is_file():
84
+ files.append(project_file)
85
+
86
+ # User-global
87
+ if _USER_CONFIG_FILE.is_file():
88
+ files.append(_USER_CONFIG_FILE)
89
+
90
+ return files
91
+
92
+
93
+ def _load_yaml_file(path: Path) -> dict[str, Any]:
94
+ """Load a YAML file, returning empty dict on parse errors."""
95
+ try:
96
+ with open(path) as f:
97
+ data = yaml.safe_load(f)
98
+ return data if isinstance(data, dict) else {}
99
+ except Exception as exc:
100
+ logger.warning("Failed to load config from %s: %s", path, exc)
101
+ return {}
102
+
103
+
104
+ def _merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
105
+ """Deep-merge override into base. Lists are replaced, not appended."""
106
+ merged = base.copy()
107
+ for key, value in override.items():
108
+ if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
109
+ merged[key] = _merge_dicts(merged[key], value)
110
+ else:
111
+ merged[key] = value
112
+ return merged
113
+
114
+
115
+ def _apply_env_overrides(data: dict[str, Any]) -> dict[str, Any]:
116
+ """Apply TRACEFORGE_* environment variables as scalar overrides.
117
+
118
+ Supports flat keys only: TRACEFORGE_LOG_LEVEL → log_level.
119
+ Nested keys use double-underscore: TRACEFORGE_SDK__BATCH_SIZE → sdk.batch_size.
120
+ """
121
+ prefix = "TRACEFORGE_"
122
+ skip = {"TRACEFORGE_CONFIG"} # meta env var, not a config field
123
+
124
+ for key, value in os.environ.items():
125
+ if not key.startswith(prefix) or key in skip:
126
+ continue
127
+
128
+ # Convert TRACEFORGE_SDK__BATCH_SIZE → ["sdk", "batch_size"]
129
+ parts = key[len(prefix) :].lower().split("__")
130
+
131
+ # Navigate to the right nesting level
132
+ target = data
133
+ for part in parts[:-1]:
134
+ if part not in target or not isinstance(target[part], dict):
135
+ target[part] = {}
136
+ target = target[part]
137
+
138
+ # Set the leaf value (attempt int/float coercion for numeric fields)
139
+ leaf = parts[-1]
140
+ target[leaf] = _coerce_env_value(value)
141
+
142
+ return data
143
+
144
+
145
+ def _coerce_env_value(value: str) -> str | int | float | bool:
146
+ """Best-effort coercion of env var string to Python scalar."""
147
+ if value.lower() in ("true", "1", "yes"):
148
+ return True
149
+ if value.lower() in ("false", "0", "no"):
150
+ return False
151
+ try:
152
+ return int(value)
153
+ except ValueError:
154
+ pass
155
+ try:
156
+ return float(value)
157
+ except ValueError:
158
+ pass
159
+ return value
160
+
161
+
162
+ def load_config(**overrides: Any) -> TraceforgeConfig:
163
+ """Load traceforge configuration with full precedence chain.
164
+
165
+ On first call, ensures ~/.traceforge/ exists with a default config.
166
+
167
+ Args:
168
+ **overrides: Explicit field values (highest precedence).
169
+
170
+ Returns:
171
+ Fully validated TraceforgeConfig instance.
172
+ """
173
+ global _config
174
+
175
+ # Bootstrap user directory (idempotent, once per process)
176
+ _ensure_user_dir()
177
+
178
+ # Start with defaults (empty dict — Pydantic fills defaults)
179
+ merged: dict[str, Any] = {}
180
+
181
+ # Layer config files (lowest priority first)
182
+ config_files = _find_config_files()
183
+ for path in reversed(config_files): # reversed so highest-priority applied last
184
+ file_data = _load_yaml_file(path)
185
+ merged = _merge_dicts(merged, file_data)
186
+ logger.debug("Loaded config from %s", path)
187
+
188
+ # Layer env vars
189
+ merged = _apply_env_overrides(merged)
190
+
191
+ # Layer explicit overrides
192
+ if overrides:
193
+ merged = _merge_dicts(merged, overrides)
194
+
195
+ # Validate and construct
196
+ _config = TraceforgeConfig.model_validate(merged)
197
+ return _config
198
+
199
+
200
+ def load_config_from_path(path: Any = None) -> TraceforgeConfig:
201
+ """Resolve a full ``TraceforgeConfig``, optionally forcing a specific file.
202
+
203
+ When ``path`` is given it becomes the sole config source (applied via the
204
+ ``TRACEFORGE_CONFIG`` override, then restored); when ``None`` the standard
205
+ discovery chain runs (``TRACEFORGE_CONFIG`` env, ``./traceforge.yaml``,
206
+ ``~/.traceforge/config.yaml``).
207
+
208
+ This is the single ``path -> config`` resolution shared by the SDK
209
+ (:meth:`traceforge.sdk.Pipeline.from_config`) and governance
210
+ (:meth:`traceforge.governance.pipeline.GovernancePipeline.from_config`)
211
+ entry points, so both hydrate from the same resolved config.
212
+ """
213
+ if path is None:
214
+ return load_config()
215
+
216
+ old_env = os.environ.get("TRACEFORGE_CONFIG")
217
+ os.environ["TRACEFORGE_CONFIG"] = str(path)
218
+ try:
219
+ return load_config()
220
+ finally:
221
+ if old_env is None:
222
+ os.environ.pop("TRACEFORGE_CONFIG", None)
223
+ else:
224
+ os.environ["TRACEFORGE_CONFIG"] = old_env
225
+
226
+
227
+ def get_config() -> TraceforgeConfig:
228
+ """Get the current config singleton, loading defaults if needed."""
229
+ global _config
230
+ if _config is None:
231
+ _config = load_config()
232
+ return _config
233
+
234
+
235
+ def reset_config() -> None:
236
+ """Reset the config singleton (for testing)."""
237
+ global _config, _bootstrapped
238
+ _config = None
239
+ _bootstrapped = False
@@ -0,0 +1,104 @@
1
+ """Mapping resolver — finds YAML framework mappings across all search paths.
2
+
3
+ Search order:
4
+ 1. User mappings dirs (from config.mappings_dirs) — allows overrides
5
+ 2. ~/.traceforge/mappings/ (default user dir)
6
+ 3. Bundled mappings (src/traceforge/mappings/) — shipped with package
7
+
8
+ First match wins. User mappings override bundled ones with the same name.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib.resources
14
+ import logging
15
+ from pathlib import Path
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Bundled mappings location (inside the installed package)
20
+ _BUNDLED_MAPPINGS_DIR: Path | None = None
21
+
22
+
23
+ def _get_bundled_dir() -> Path:
24
+ """Resolve the bundled mappings directory from the installed package."""
25
+ global _BUNDLED_MAPPINGS_DIR
26
+ if _BUNDLED_MAPPINGS_DIR is None:
27
+ ref = importlib.resources.files("traceforge") / "mappings"
28
+ # Traversable → Path (works for both installed and editable installs)
29
+ _BUNDLED_MAPPINGS_DIR = Path(str(ref))
30
+ return _BUNDLED_MAPPINGS_DIR
31
+
32
+
33
+ def resolve_mapping_path(name: str, extra_dirs: list[Path] | None = None) -> Path | None:
34
+ """Find a YAML mapping file by framework name.
35
+
36
+ Args:
37
+ name: Framework name (e.g. "copilot", "claude"). Resolved as {name}.yaml.
38
+ extra_dirs: Additional directories to search (from config.mappings_dirs).
39
+
40
+ Returns:
41
+ Path to the YAML file, or None if not found.
42
+ """
43
+ filename = f"{name}.yaml"
44
+ search_dirs: list[Path] = []
45
+
46
+ # User-specified dirs (highest priority)
47
+ if extra_dirs:
48
+ for d in extra_dirs:
49
+ resolved = Path(d).expanduser().resolve()
50
+ if resolved.is_dir():
51
+ search_dirs.append(resolved)
52
+
53
+ # Default user mappings dir
54
+ user_mappings = Path.home() / ".traceforge" / "mappings"
55
+ if user_mappings.is_dir() and user_mappings not in search_dirs:
56
+ search_dirs.append(user_mappings)
57
+
58
+ # Bundled mappings (lowest priority)
59
+ bundled = _get_bundled_dir()
60
+ if bundled.is_dir():
61
+ search_dirs.append(bundled)
62
+
63
+ # First match wins
64
+ for directory in search_dirs:
65
+ candidate = directory / filename
66
+ if candidate.is_file():
67
+ return candidate
68
+
69
+ return None
70
+
71
+
72
+ def list_available_mappings(extra_dirs: list[Path] | None = None) -> dict[str, Path]:
73
+ """List all available mapping names and their resolved paths.
74
+
75
+ Later entries do not override earlier ones (first-found wins).
76
+
77
+ Returns:
78
+ Dict of framework_name → path.
79
+ """
80
+ mappings: dict[str, Path] = {}
81
+ search_dirs: list[Path] = []
82
+
83
+ # Build search order (same as resolve_mapping_path)
84
+ if extra_dirs:
85
+ for d in extra_dirs:
86
+ resolved = Path(d).expanduser().resolve()
87
+ if resolved.is_dir():
88
+ search_dirs.append(resolved)
89
+
90
+ user_mappings = Path.home() / ".traceforge" / "mappings"
91
+ if user_mappings.is_dir() and user_mappings not in search_dirs:
92
+ search_dirs.append(user_mappings)
93
+
94
+ bundled = _get_bundled_dir()
95
+ if bundled.is_dir():
96
+ search_dirs.append(bundled)
97
+
98
+ for directory in search_dirs:
99
+ for yaml_file in sorted(directory.glob("*.yaml")):
100
+ name = yaml_file.stem
101
+ if name not in mappings: # first-found wins (user overrides bundled)
102
+ mappings[name] = yaml_file
103
+
104
+ return mappings