codrspot-processor-mcp 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 (100) hide show
  1. codepreproc_client/__init__.py +14 -0
  2. codepreproc_client/__main__.py +9 -0
  3. codepreproc_client/layer1_business/__init__.py +0 -0
  4. codepreproc_client/layer1_business/allowed_scope_cache.py +62 -0
  5. codepreproc_client/layer1_business/api_client/__init__.py +0 -0
  6. codepreproc_client/layer1_business/api_client/lease_client.py +34 -0
  7. codepreproc_client/layer1_business/api_client/net_guard.py +98 -0
  8. codepreproc_client/layer1_business/api_client/promote_client.py +59 -0
  9. codepreproc_client/layer1_business/api_client/reasoning_client.py +163 -0
  10. codepreproc_client/layer1_business/api_client/registry_client.py +119 -0
  11. codepreproc_client/layer1_business/api_client/superkg_client.py +327 -0
  12. codepreproc_client/layer1_business/cli/__init__.py +0 -0
  13. codepreproc_client/layer1_business/cli/init.py +578 -0
  14. codepreproc_client/layer1_business/cli/main.py +78 -0
  15. codepreproc_client/layer1_business/config.py +104 -0
  16. codepreproc_client/layer1_business/git_utils.py +67 -0
  17. codepreproc_client/layer1_business/license/__init__.py +0 -0
  18. codepreproc_client/layer1_business/license/fingerprint.py +61 -0
  19. codepreproc_client/layer1_business/license/jwt_client.py +60 -0
  20. codepreproc_client/layer1_business/license/project_lock.py +44 -0
  21. codepreproc_client/layer1_business/local_registry.py +40 -0
  22. codepreproc_client/layer1_business/manifest/__init__.py +0 -0
  23. codepreproc_client/layer1_business/manifest/credentials.py +35 -0
  24. codepreproc_client/layer1_business/mcp/__init__.py +0 -0
  25. codepreproc_client/layer1_business/mcp/action_monitor.py +246 -0
  26. codepreproc_client/layer1_business/mcp/lifecycle.py +649 -0
  27. codepreproc_client/layer1_business/mcp/server.py +656 -0
  28. codepreproc_client/layer1_business/mcp/tools.py +1665 -0
  29. codepreproc_client/layer1_business/project_registry.py +99 -0
  30. codepreproc_client/layer2_tooling/__init__.py +0 -0
  31. codepreproc_client/layer2_tooling/condensation/__init__.py +0 -0
  32. codepreproc_client/layer2_tooling/condensation/ast_graph.py +426 -0
  33. codepreproc_client/layer2_tooling/condensation/bm25_store.py +132 -0
  34. codepreproc_client/layer2_tooling/condensation/chunking/__init__.py +0 -0
  35. codepreproc_client/layer2_tooling/condensation/chunking/ast_chunker.py +313 -0
  36. codepreproc_client/layer2_tooling/condensation/chunking/languages.py +238 -0
  37. codepreproc_client/layer2_tooling/condensation/embed_worker.py +128 -0
  38. codepreproc_client/layer2_tooling/condensation/embeddings.py +452 -0
  39. codepreproc_client/layer2_tooling/condensation/extract/__init__.py +0 -0
  40. codepreproc_client/layer2_tooling/condensation/extract/compactor.py +103 -0
  41. codepreproc_client/layer2_tooling/condensation/extract/providers/__init__.py +0 -0
  42. codepreproc_client/layer2_tooling/condensation/extract/providers/base.py +38 -0
  43. codepreproc_client/layer2_tooling/condensation/indexer.py +945 -0
  44. codepreproc_client/layer2_tooling/condensation/ppl/__init__.py +0 -0
  45. codepreproc_client/layer2_tooling/condensation/ppl/emitter.py +162 -0
  46. codepreproc_client/layer2_tooling/condensation/ppl/parser.py +352 -0
  47. codepreproc_client/layer2_tooling/condensation/qdrant_store.py +387 -0
  48. codepreproc_client/layer2_tooling/condensation/scanning/__init__.py +0 -0
  49. codepreproc_client/layer2_tooling/condensation/scanning/manifest_scanner.py +72 -0
  50. codepreproc_client/layer2_tooling/condensation/scanning/md_scanner.py +97 -0
  51. codepreproc_client/layer2_tooling/condensation/scanning/repo_mapper.py +495 -0
  52. codepreproc_client/layer2_tooling/condensation/session_vector_store.py +69 -0
  53. codepreproc_client/layer2_tooling/coupling/__init__.py +0 -0
  54. codepreproc_client/layer2_tooling/coupling/domain.py +263 -0
  55. codepreproc_client/layer2_tooling/coupling/graph.py +255 -0
  56. codepreproc_client/layer2_tooling/coupling/layer2_gate.py +189 -0
  57. codepreproc_client/layer2_tooling/coupling/projector.py +139 -0
  58. codepreproc_client/layer2_tooling/coupling/resolver.py +150 -0
  59. codepreproc_client/layer2_tooling/materialization/__init__.py +0 -0
  60. codepreproc_client/layer2_tooling/materialization/pipeline/__init__.py +0 -0
  61. codepreproc_client/layer2_tooling/materialization/pipeline/execute_semantic.py +1249 -0
  62. codepreproc_client/layer2_tooling/materialization/pipeline/filesystem_reorg.py +575 -0
  63. codepreproc_client/layer2_tooling/materialization/pipeline/patch_generator.py +127 -0
  64. codepreproc_client/layer2_tooling/materialization/pipeline/patch_validator.py +101 -0
  65. codepreproc_client/layer2_tooling/materialization/semantic/__init__.py +19 -0
  66. codepreproc_client/layer2_tooling/materialization/semantic/anchors.py +61 -0
  67. codepreproc_client/layer2_tooling/materialization/semantic/applicability.py +75 -0
  68. codepreproc_client/layer2_tooling/materialization/semantic/edit_planner.py +264 -0
  69. codepreproc_client/layer2_tooling/materialization/semantic/materializer.py +121 -0
  70. codepreproc_client/layer2_tooling/materialization/semantic/merger.py +73 -0
  71. codepreproc_client/layer2_tooling/materialization/semantic/ops/__init__.py +0 -0
  72. codepreproc_client/layer2_tooling/materialization/semantic/ops/add_import.py +36 -0
  73. codepreproc_client/layer2_tooling/materialization/semantic/ops/append_argument.py +37 -0
  74. codepreproc_client/layer2_tooling/materialization/semantic/ops/insert_literal.py +24 -0
  75. codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_import.py +33 -0
  76. codepreproc_client/layer2_tooling/materialization/semantic/ops/remove_statement_unique.py +14 -0
  77. codepreproc_client/layer2_tooling/materialization/semantic/ops/rename_symbol_local.py +16 -0
  78. codepreproc_client/layer2_tooling/materialization/semantic/region_resolver.py +229 -0
  79. codepreproc_client/layer2_tooling/materialization/semantic/synthesizer.py +186 -0
  80. codepreproc_client/layer2_tooling/materialization/semantic/target_locator.py +94 -0
  81. codepreproc_client/layer2_tooling/materialization/semantic/validator.py +322 -0
  82. codepreproc_client/layer2_tooling/materialization/snippet/__init__.py +0 -0
  83. codepreproc_client/layer2_tooling/materialization/snippet/assembler.py +503 -0
  84. codepreproc_client/layer2_tooling/materialization/snippet/loader.py +187 -0
  85. codepreproc_client/layer2_tooling/retrieval/__init__.py +0 -0
  86. codepreproc_client/layer2_tooling/retrieval/graph_walk.py +47 -0
  87. codepreproc_client/layer2_tooling/retrieval/hybrid.py +95 -0
  88. codepreproc_client/layer2_tooling/retrieval/rerank_worker.py +86 -0
  89. codepreproc_client/layer2_tooling/retrieval/reranker.py +213 -0
  90. codepreproc_client/layer2_tooling/watcher/__init__.py +0 -0
  91. codepreproc_client/layer2_tooling/watcher/fs_watcher.py +3 -0
  92. codrspot_processor_mcp-0.1.0.dist-info/METADATA +396 -0
  93. codrspot_processor_mcp-0.1.0.dist-info/RECORD +100 -0
  94. codrspot_processor_mcp-0.1.0.dist-info/WHEEL +5 -0
  95. codrspot_processor_mcp-0.1.0.dist-info/entry_points.txt +3 -0
  96. codrspot_processor_mcp-0.1.0.dist-info/licenses/LICENSE +23 -0
  97. codrspot_processor_mcp-0.1.0.dist-info/top_level.txt +2 -0
  98. contracts/__init__.py +0 -0
  99. contracts/dtos.py +1239 -0
  100. contracts/ir.py +102 -0
@@ -0,0 +1,503 @@
1
+ """Two-phase snippet assembler (Subfase 4.2 + 4.3).
2
+
3
+ Phase 1 (low-cost model): intent resolution → snippet selection → variable binding
4
+ → scope definition → template instantiation → AssemblyPayload.
5
+ Phase 2 (high-capability model): controlled integration → files written to disk.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from codepreproc_client.layer1_business.api_client.reasoning_client import LLMRouter
17
+ from contracts.dtos import (
18
+ AssemblyExecutionPack,
19
+ AssemblyIntent,
20
+ AssemblyPayload,
21
+ AssemblyResult,
22
+ AssemblyScope,
23
+ FailureReason,
24
+ InstantiatedSnippet,
25
+ LLMPolicy,
26
+ LLMUsageStats,
27
+ ProjectConfig,
28
+ SnippetSelection,
29
+ VariableBinding,
30
+ )
31
+ from codepreproc_client.layer2_tooling.materialization.snippet.loader import SnippetLoader
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _ASSEMBLY_SYSTEM_PROMPT = """\
36
+ You are a DDD snippet assembly planner. You select snippets from a library and resolve variables.
37
+ You must respond ONLY with valid JSON. No markdown, no explanation.
38
+
39
+ DDD layer rules:
40
+ - domain: no external dependencies
41
+ - application: orchestrates domain, no infrastructure or interface imports
42
+ - infrastructure: implements adapters, no interface imports
43
+ - interface: handles IO, may import application and domain
44
+
45
+ Forbidden actions:
46
+ - generating code without a snippet
47
+ - modifying out-of-scope files
48
+ - introducing new architecture
49
+ - duplicating logic
50
+ - cross-layer violations
51
+ """
52
+
53
+ _INTEGRATION_SYSTEM_PROMPT = """\
54
+ You are a DDD integration specialist. You integrate pre-instantiated code snippets into a codebase.
55
+
56
+ Rules:
57
+ - Do not rewrite entire files unless required
58
+ - Preserve existing logic
59
+ - Merge changes locally
60
+ - Respect imports and structure
61
+ - Maintain layer boundaries
62
+ - No missing imports
63
+ - No unused variables
64
+ - No broken references
65
+ - No duplicate logic
66
+ - Consistent naming
67
+ - Valid DDD layering
68
+
69
+ Output format: JSON with keys "files_modified" (list of paths), "files_created" (list of paths),
70
+ "integration_notes" (list of strings explaining integration decisions).
71
+ No explanations outside the JSON.
72
+ """
73
+
74
+ _DDD_CONSTRAINTS = [
75
+ "domain has no external dependencies",
76
+ "application orchestrates domain only",
77
+ "infrastructure implements adapters, no interface imports",
78
+ "interface handles IO, may import application and domain",
79
+ "no circular dependencies between layers",
80
+ "no free code generation outside snippet templates",
81
+ "no modifications to out-of-scope files",
82
+ ]
83
+
84
+
85
+ class SnippetAssembler:
86
+ """Runs the two-phase assembly pipeline."""
87
+
88
+ def __init__(
89
+ self,
90
+ loader: SnippetLoader,
91
+ router: LLMRouter,
92
+ project: ProjectConfig,
93
+ logger: logging.Logger | None = None,
94
+ ) -> None:
95
+ self._loader = loader
96
+ self._router = router
97
+ self._project = project
98
+ self._logger = logger or logging.getLogger(__name__)
99
+
100
+ async def assemble(
101
+ self,
102
+ user_prompt: str,
103
+ task_id: str,
104
+ policy: LLMPolicy,
105
+ ) -> AssemblyExecutionPack:
106
+ """Run both phases and return the execution pack."""
107
+ usage_log: list[LLMUsageStats] = []
108
+
109
+ # ------------------------------------------------------------------
110
+ # Phase 1: intent → selection → variables → scope → instantiation
111
+ # ------------------------------------------------------------------
112
+ intent, usage = await self._resolve_intent(user_prompt, policy)
113
+ usage_log.append(usage)
114
+ if intent is None:
115
+ return AssemblyExecutionPack(
116
+ success=False,
117
+ task_id=task_id,
118
+ project_id=self._project.project_id,
119
+ failure=FailureReason(
120
+ code="ASSEMBLY_INTENT_FAILED",
121
+ stage="intent_resolution",
122
+ message="Low-cost model could not resolve snippet assembly intent",
123
+ actionable_hint="Ensure the prompt specifies entity, action, framework and language",
124
+ ),
125
+ llm_usage=usage_log,
126
+ )
127
+
128
+ available = self._loader.list_snippets(framework=intent.framework, language=intent.language)
129
+ if not available:
130
+ return AssemblyExecutionPack(
131
+ success=False,
132
+ task_id=task_id,
133
+ project_id=self._project.project_id,
134
+ intent=intent,
135
+ failure=FailureReason(
136
+ code="NO_SNIPPETS_AVAILABLE",
137
+ stage="snippet_selection",
138
+ message=f"No snippets for framework={intent.framework} language={intent.language}",
139
+ actionable_hint="Check snippets in the cp_snippets PostgreSQL table and the snippets/ directory",
140
+ ),
141
+ llm_usage=usage_log,
142
+ )
143
+
144
+ selection, usage = await self._select_snippets(user_prompt, intent, available, policy)
145
+ usage_log.append(usage)
146
+
147
+ variables, usage = await self._resolve_variables(user_prompt, intent, policy)
148
+ usage_log.append(usage)
149
+
150
+ scope = self._build_scope(selection, variables, intent)
151
+
152
+ snippets = self._loader.get_snippets_by_ids(
153
+ selection.selected_snippet_ids, intent.framework, intent.language
154
+ )
155
+ if not snippets:
156
+ return AssemblyExecutionPack(
157
+ success=False,
158
+ task_id=task_id,
159
+ project_id=self._project.project_id,
160
+ intent=intent,
161
+ selection=selection,
162
+ failure=FailureReason(
163
+ code="NO_SNIPPETS_SELECTED",
164
+ stage="snippet_selection",
165
+ message="No valid snippets could be loaded for the selected IDs",
166
+ actionable_hint=f"Selected IDs: {selection.selected_snippet_ids}. "
167
+ "Verify ids exist in the registry for the resolved framework/language.",
168
+ ),
169
+ llm_usage=usage_log,
170
+ )
171
+ instantiated = [self._instantiate(s, variables) for s in snippets]
172
+
173
+ payload = AssemblyPayload(
174
+ snippets=instantiated,
175
+ variables=variables,
176
+ scope=scope,
177
+ target_files=[path for s in instantiated for path in s.produces],
178
+ ddd_constraints=_DDD_CONSTRAINTS,
179
+ )
180
+
181
+ # ------------------------------------------------------------------
182
+ # Phase 2: controlled integration
183
+ # ------------------------------------------------------------------
184
+ result, usage = await self._integrate(user_prompt, payload, policy)
185
+ usage_log.append(usage)
186
+
187
+ # Write files to disk
188
+ written = self._write_files(instantiated, payload.scope)
189
+
190
+ return AssemblyExecutionPack(
191
+ success=True,
192
+ task_id=task_id,
193
+ project_id=self._project.project_id,
194
+ intent=intent,
195
+ selection=selection,
196
+ variables=variables,
197
+ payload=payload,
198
+ result=result or AssemblyResult(
199
+ files_created=written,
200
+ integration_notes=["Files written from instantiated snippets"],
201
+ ),
202
+ llm_usage=usage_log,
203
+ )
204
+
205
+ # ------------------------------------------------------------------
206
+ # Phase 1 steps
207
+ # ------------------------------------------------------------------
208
+
209
+ async def _resolve_intent(
210
+ self, user_prompt: str, policy: LLMPolicy
211
+ ) -> tuple[AssemblyIntent | None, LLMUsageStats]:
212
+ available_frameworks = list({
213
+ s.framework for s in self._loader.list_snippets()
214
+ })
215
+ available_languages = list({
216
+ s.language for s in self._loader.list_snippets()
217
+ })
218
+ messages = [
219
+ {"role": "system", "content": _ASSEMBLY_SYSTEM_PROMPT},
220
+ {
221
+ "role": "user",
222
+ "content": (
223
+ f"User request: {user_prompt}\n\n"
224
+ f"Available frameworks: {available_frameworks}\n"
225
+ f"Available languages: {available_languages}\n\n"
226
+ "Respond with JSON:\n"
227
+ '{"intent": "...", "entity": "EntityName", "action": "create|update|delete|list", '
228
+ '"framework": "nest|fastapi|flutter|laravel", "language": "typescript|python|dart|php", "pattern": "ddd"}'
229
+ ),
230
+ },
231
+ ]
232
+ result, usage = await self._router.chat(
233
+ task_type="snippet_assembly",
234
+ policy=policy,
235
+ messages=messages,
236
+ schema=None,
237
+ max_tokens=256,
238
+ temperature=0.0,
239
+ project_id=self._project.project_id,
240
+ task_id="intent",
241
+ )
242
+ try:
243
+ data = _parse_json(result)
244
+ return AssemblyIntent.model_validate(data), usage
245
+ except Exception as exc:
246
+ self._logger.warning("Intent resolution failed: %s", exc)
247
+ return None, usage
248
+
249
+ async def _select_snippets(
250
+ self,
251
+ user_prompt: str,
252
+ intent: AssemblyIntent,
253
+ available: list,
254
+ policy: LLMPolicy,
255
+ ) -> tuple[SnippetSelection, LLMUsageStats]:
256
+ snippet_list = "\n".join(
257
+ f"- id={s.id} layer={s.layer} tags={s.tags}" for s in available
258
+ )
259
+ messages = [
260
+ {"role": "system", "content": _ASSEMBLY_SYSTEM_PROMPT},
261
+ {
262
+ "role": "user",
263
+ "content": (
264
+ f"User request: {user_prompt}\n"
265
+ f"Intent: {intent.model_dump_json()}\n\n"
266
+ f"Available snippets for {intent.framework}/{intent.language}:\n{snippet_list}\n\n"
267
+ "Select only the snippets needed. Use exact ids. Respond with JSON:\n"
268
+ '{"selected_snippet_ids": ["id1", "id2"], "rationale": "..."}'
269
+ ),
270
+ },
271
+ ]
272
+ result, usage = await self._router.chat(
273
+ task_type="snippet_assembly",
274
+ policy=policy,
275
+ messages=messages,
276
+ schema=None,
277
+ max_tokens=256,
278
+ temperature=0.0,
279
+ project_id=self._project.project_id,
280
+ task_id="selection",
281
+ )
282
+ try:
283
+ data = _parse_json(result)
284
+ return SnippetSelection.model_validate(data), usage
285
+ except Exception:
286
+ # Fallback: select all available snippets
287
+ return SnippetSelection(
288
+ selected_snippet_ids=[s.id for s in available],
289
+ rationale="fallback: all available snippets selected",
290
+ ), usage
291
+
292
+ async def _resolve_variables(
293
+ self, user_prompt: str, intent: AssemblyIntent, policy: LLMPolicy
294
+ ) -> tuple[VariableBinding, LLMUsageStats]:
295
+ messages = [
296
+ {"role": "system", "content": _ASSEMBLY_SYSTEM_PROMPT},
297
+ {
298
+ "role": "user",
299
+ "content": (
300
+ f"Entity: {intent.entity}\n"
301
+ f"Framework: {intent.framework}\n\n"
302
+ "Derive variable values. Rules:\n"
303
+ "- EntityName: PascalCase of the entity\n"
304
+ "- entityName: camelCase (TypeScript/Dart) or snake_case (Python/PHP)\n"
305
+ "- moduleName: kebab-case (NestJS), snake_case (FastAPI/Flutter/Laravel)\n\n"
306
+ "Respond with JSON:\n"
307
+ '{"EntityName": "...", "entityName": "...", "moduleName": "..."}'
308
+ ),
309
+ },
310
+ ]
311
+ result, usage = await self._router.chat(
312
+ task_type="snippet_assembly",
313
+ policy=policy,
314
+ messages=messages,
315
+ schema=None,
316
+ max_tokens=128,
317
+ temperature=0.0,
318
+ project_id=self._project.project_id,
319
+ task_id="variables",
320
+ )
321
+ try:
322
+ data = _parse_json(result)
323
+ return VariableBinding.model_validate(data), usage
324
+ except Exception:
325
+ # Fallback: derive from intent.entity
326
+ entity = intent.entity
327
+ entity_pascal = _to_pascal(entity)
328
+ if intent.language in {"python", "dart", "php"}:
329
+ entity_lower = _to_snake(entity)
330
+ module = _to_snake(entity)
331
+ else:
332
+ entity_lower = _to_camel(entity)
333
+ module = _to_kebab(entity)
334
+ return VariableBinding(
335
+ EntityName=entity_pascal,
336
+ entityName=entity_lower,
337
+ moduleName=module,
338
+ ), usage
339
+
340
+ # ------------------------------------------------------------------
341
+ # Phase 2: controlled integration
342
+ # ------------------------------------------------------------------
343
+
344
+ async def _integrate(
345
+ self,
346
+ user_prompt: str,
347
+ payload: AssemblyPayload,
348
+ policy: LLMPolicy,
349
+ ) -> tuple[AssemblyResult | None, LLMUsageStats]:
350
+ snippets_summary = "\n".join(
351
+ f"- {s.snippet_id} ({s.layer}) → {s.produces}"
352
+ for s in payload.snippets
353
+ )
354
+ messages = [
355
+ {"role": "system", "content": _INTEGRATION_SYSTEM_PROMPT},
356
+ {
357
+ "role": "user",
358
+ "content": (
359
+ f"Request: {user_prompt}\n\n"
360
+ f"Variables: {payload.variables.model_dump_json()}\n\n"
361
+ f"Snippets to integrate:\n{snippets_summary}\n\n"
362
+ f"Scope — allowed files:\n" + "\n".join(f" - {f}" for f in payload.scope.allowed_files) + "\n\n"
363
+ f"DDD constraints:\n" + "\n".join(f" - {c}" for c in payload.ddd_constraints) + "\n\n"
364
+ "Respond with JSON:\n"
365
+ '{"files_modified": [], "files_created": ["path1", ...], "integration_notes": ["..."]}'
366
+ ),
367
+ },
368
+ ]
369
+ result, usage = await self._router.chat(
370
+ task_type="snippet_integration",
371
+ policy=policy,
372
+ messages=messages,
373
+ schema=None,
374
+ max_tokens=512,
375
+ temperature=0.0,
376
+ project_id=self._project.project_id,
377
+ task_id="integration",
378
+ )
379
+ try:
380
+ data = _parse_json(result)
381
+ return AssemblyResult.model_validate(data), usage
382
+ except Exception as exc:
383
+ self._logger.warning(
384
+ "Integration phase failed — files will be written without LLM guidance: %s", exc
385
+ )
386
+ return None, usage
387
+
388
+ # ------------------------------------------------------------------
389
+ # File writing
390
+ # ------------------------------------------------------------------
391
+
392
+ def _build_scope(
393
+ self, selection: SnippetSelection, variables: VariableBinding, intent: AssemblyIntent
394
+ ) -> AssemblyScope:
395
+ snippets = self._loader.get_snippets_by_ids(
396
+ selection.selected_snippet_ids, intent.framework, intent.language
397
+ )
398
+ allowed: list[str] = []
399
+ for spec in snippets:
400
+ for path_template in spec.produces:
401
+ resolved = _apply_variables(path_template, variables)
402
+ allowed.append(resolved)
403
+ return AssemblyScope(
404
+ allowed_files=allowed,
405
+ forbidden=["global_config", "unrelated_modules"],
406
+ )
407
+
408
+ def _instantiate(self, spec: Any, variables: VariableBinding) -> InstantiatedSnippet:
409
+ code = _apply_variables(spec.template, variables)
410
+ produces = [_apply_variables(p, variables) for p in spec.produces]
411
+ return InstantiatedSnippet(
412
+ snippet_id=spec.id,
413
+ framework=spec.framework,
414
+ language=spec.language,
415
+ layer=spec.layer,
416
+ produces=produces,
417
+ instantiated_code=code,
418
+ integration=spec.integration,
419
+ )
420
+
421
+ def _write_files(self, snippets: list[InstantiatedSnippet], scope: AssemblyScope) -> list[str]:
422
+ written: list[str] = []
423
+ for snippet in snippets:
424
+ for i, relative_path in enumerate(snippet.produces):
425
+ if relative_path not in scope.allowed_files:
426
+ self._logger.warning("Skipping out-of-scope file: %s", relative_path)
427
+ continue
428
+ full_path = self._project.root / relative_path
429
+ full_path.parent.mkdir(parents=True, exist_ok=True)
430
+ full_path.write_text(snippet.instantiated_code, encoding="utf-8")
431
+ written.append(str(full_path))
432
+ self._logger.info("Written: %s", full_path)
433
+ return written
434
+
435
+
436
+ # ------------------------------------------------------------------
437
+ # Utility helpers
438
+ # ------------------------------------------------------------------
439
+
440
+ def _parse_json(result: Any) -> dict:
441
+ """Extract first valid JSON object from LLM result.
442
+
443
+ Tolerates: markdown code fences, surrounding explanation text,
444
+ non-string content (empty dict from FakeProvider, etc.).
445
+ """
446
+ if isinstance(result, dict):
447
+ content = result.get("content", "")
448
+ if isinstance(content, dict):
449
+ # Provider returned a pre-parsed dict (e.g. FakeProvider) — use directly
450
+ return content
451
+ if not isinstance(content, str):
452
+ content = ""
453
+ else:
454
+ content = str(result)
455
+
456
+ # Strip markdown code fences (``` or ```json)
457
+ content = re.sub(r"```(?:json)?\s*", "", content)
458
+ content = content.strip()
459
+
460
+ # 1. Try parsing the whole cleaned content directly
461
+ try:
462
+ return json.loads(content)
463
+ except json.JSONDecodeError:
464
+ pass
465
+
466
+ # 2. Find the first {...} block in case there is surrounding text
467
+ match = re.search(r"\{.*\}", content, re.DOTALL)
468
+ if match:
469
+ try:
470
+ return json.loads(match.group())
471
+ except json.JSONDecodeError:
472
+ pass
473
+
474
+ raise ValueError(f"No valid JSON object found in LLM response: {content[:300]!r}")
475
+
476
+
477
+ def _apply_variables(template: str, variables: VariableBinding) -> str:
478
+ result = template
479
+ result = result.replace("{{EntityName}}", variables.EntityName)
480
+ result = result.replace("{{entityName}}", variables.entityName)
481
+ result = result.replace("{{moduleName}}", variables.moduleName)
482
+ for key, value in variables.extra.items():
483
+ result = result.replace(f"{{{{{key}}}}}", value)
484
+ return result
485
+
486
+
487
+ def _to_pascal(name: str) -> str:
488
+ return "".join(word.capitalize() for word in re.split(r"[_\-\s]+", name) if word)
489
+
490
+
491
+ def _to_camel(name: str) -> str:
492
+ words = [w for w in re.split(r"[_\-\s]+", name) if w]
493
+ return words[0].lower() + "".join(w.capitalize() for w in words[1:]) if words else name
494
+
495
+
496
+ def _to_snake(name: str) -> str:
497
+ s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
498
+ s = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s)
499
+ return re.sub(r"[\-\s]+", "_", s).lower()
500
+
501
+
502
+ def _to_kebab(name: str) -> str:
503
+ return _to_snake(name).replace("_", "-")
@@ -0,0 +1,187 @@
1
+ """Loads snippets from the registry and snippet files (or PostgreSQL)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Iterator
8
+
9
+ import yaml
10
+
11
+ from contracts.dtos import SnippetSpec
12
+
13
+ if TYPE_CHECKING:
14
+ pass
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class SnippetLoader:
20
+ """Reads snippet_registry.yaml and resolves snippet files relative to a base dir."""
21
+
22
+ def __init__(self, registry_path: Path, base_dir: Path | None = None) -> None:
23
+ self._registry_path = registry_path
24
+ # base_dir defaults to registry_path.parent.parent:
25
+ # registry/snippet_registry.yaml → registry → home_dir
26
+ self._base_dir = base_dir or registry_path.parent.parent
27
+ self._registry: dict | None = None
28
+ # Tuple key (id, framework, language) avoids "::"-separator collisions
29
+ self._cache: dict[tuple[str, str, str], SnippetSpec] = {}
30
+
31
+ # ------------------------------------------------------------------
32
+ # Public API
33
+ # ------------------------------------------------------------------
34
+
35
+ def list_snippets(
36
+ self,
37
+ *,
38
+ framework: str | None = None,
39
+ language: str | None = None,
40
+ layer: str | None = None,
41
+ tags: list[str] | None = None,
42
+ ) -> list[SnippetSpec]:
43
+ """Return all snippets matching the given filters."""
44
+ results = []
45
+ for spec in self._iter_registry_entries():
46
+ if framework and spec.framework != framework:
47
+ continue
48
+ if language and spec.language != language:
49
+ continue
50
+ if layer and spec.layer != layer:
51
+ continue
52
+ if tags and not all(t in spec.tags for t in tags):
53
+ continue
54
+ results.append(spec)
55
+ return results
56
+
57
+ def get_snippet(self, snippet_id: str, framework: str, language: str) -> SnippetSpec | None:
58
+ """Return a fully loaded snippet by (id, framework, language)."""
59
+ cache_key = (snippet_id, framework, language)
60
+ if cache_key in self._cache:
61
+ return self._cache[cache_key]
62
+
63
+ for entry in self._registry_data().get("snippets", []):
64
+ if (
65
+ entry.get("id") == snippet_id
66
+ and entry.get("framework") == framework
67
+ and entry.get("language") == language
68
+ ):
69
+ spec = self._load_snippet_file(entry)
70
+ if spec is not None:
71
+ self._cache[cache_key] = spec
72
+ return spec
73
+ return None
74
+
75
+ def get_snippets_by_ids(self, ids: list[str], framework: str, language: str) -> list[SnippetSpec]:
76
+ """Return snippets in full transitive dependency order (DFS, deepest deps first)."""
77
+ seen: set[str] = set()
78
+ result: list[SnippetSpec] = []
79
+ for snippet_id in ids:
80
+ self._collect_deps(snippet_id, framework, language, seen, result)
81
+ return result
82
+
83
+ def _collect_deps(
84
+ self,
85
+ snippet_id: str,
86
+ framework: str,
87
+ language: str,
88
+ seen: set[str],
89
+ result: list[SnippetSpec],
90
+ ) -> None:
91
+ """DFS: insert all transitive dependencies before the snippet itself."""
92
+ if snippet_id in seen:
93
+ return
94
+ seen.add(snippet_id)
95
+ spec = self.get_snippet(snippet_id, framework, language)
96
+ if spec is None:
97
+ return
98
+ for req_id in spec.requires:
99
+ self._collect_deps(req_id, framework, language, seen, result)
100
+ result.append(spec)
101
+
102
+ # ------------------------------------------------------------------
103
+ # Private helpers
104
+ # ------------------------------------------------------------------
105
+
106
+ def _registry_data(self) -> dict:
107
+ if self._registry is None:
108
+ if not self._registry_path.exists():
109
+ logger.warning("snippet_registry.yaml not found at %s", self._registry_path)
110
+ self._registry = {}
111
+ else:
112
+ with self._registry_path.open(encoding="utf-8") as fh:
113
+ self._registry = yaml.safe_load(fh) or {}
114
+ return self._registry
115
+
116
+ def _iter_registry_entries(self) -> Iterator[SnippetSpec]:
117
+ for entry in self._registry_data().get("snippets", []):
118
+ cache_key = (entry.get("id", ""), entry.get("framework", ""), entry.get("language", ""))
119
+ if cache_key in self._cache:
120
+ yield self._cache[cache_key]
121
+ continue
122
+ spec = self._load_snippet_file(entry)
123
+ if spec is not None:
124
+ yield spec
125
+
126
+ def _load_snippet_file(self, entry: dict) -> SnippetSpec | None:
127
+ relative_path = entry.get("path", "")
128
+ full_path = self._base_dir / relative_path
129
+ if not full_path.exists():
130
+ logger.warning("Snippet file not found: %s", full_path)
131
+ return None
132
+ try:
133
+ with full_path.open(encoding="utf-8") as fh:
134
+ data = yaml.safe_load(fh) or {}
135
+ # Merge registry-level metadata with file-level data
136
+ merged = {**entry, **data}
137
+ merged["path"] = relative_path
138
+ return SnippetSpec.model_validate(merged)
139
+ except Exception as exc:
140
+ logger.warning("Failed to load snippet %s: %s", full_path, exc)
141
+ return None
142
+
143
+ # ------------------------------------------------------------------
144
+ # API-backed factory
145
+ # ------------------------------------------------------------------
146
+
147
+ @classmethod
148
+ async def from_api(cls, *, base_url: str, jwt: str | None, base_dir: Path | None = None) -> "SnippetLoader":
149
+ """Build a SnippetLoader pre-populated from GET /v1/snippets.
150
+
151
+ Falls back to an empty snippet library (rather than raising) if no
152
+ bootstrap JWT is available yet - e.g. before any project is
153
+ registered - or the server is unreachable.
154
+ """
155
+ sentinel = Path("/dev/null")
156
+ instance = cls(registry_path=sentinel, base_dir=base_dir)
157
+
158
+ rows = []
159
+ if jwt is not None:
160
+ try:
161
+ from codepreproc_client.layer1_business.api_client.registry_client import list_snippets
162
+
163
+ rows = await list_snippets(base_url=base_url, jwt=jwt)
164
+ except Exception:
165
+ logger.warning("SnippetLoader: failed to load snippets from the API, starting empty", exc_info=True)
166
+
167
+ for spec in rows:
168
+ cache_key = (spec.id, spec.framework, spec.language)
169
+ instance._cache[cache_key] = spec
170
+ # Also populate _registry so list_snippets() works via _iter_registry_entries
171
+ # Build an in-memory registry structure so filtering still works
172
+ instance._registry = {
173
+ "snippets": [
174
+ {
175
+ "id": spec.id,
176
+ "framework": spec.framework,
177
+ "language": spec.language,
178
+ "type": spec.type,
179
+ "layer": spec.layer,
180
+ "tags": spec.tags,
181
+ "path": spec.path,
182
+ }
183
+ for spec in rows
184
+ ]
185
+ }
186
+ logger.info("SnippetLoader: %d snippets loaded from the API", len(rows))
187
+ return instance