opencode-arch 1.0.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 (65) hide show
  1. opencode_arch/__init__.py +3 -0
  2. opencode_arch/artifacts/__init__.py +48 -0
  3. opencode_arch/artifacts/context.py +451 -0
  4. opencode_arch/artifacts/diagrams.py +451 -0
  5. opencode_arch/artifacts/selector.py +331 -0
  6. opencode_arch/artifacts/templates.py +444 -0
  7. opencode_arch/cli/__init__.py +1 -0
  8. opencode_arch/cli/bench.py +25 -0
  9. opencode_arch/cli/calibrate.py +208 -0
  10. opencode_arch/cli/confidence.py +66 -0
  11. opencode_arch/cli/docs.py +333 -0
  12. opencode_arch/cli/docs_validator.py +295 -0
  13. opencode_arch/cli/export_data.py +133 -0
  14. opencode_arch/cli/extract.py +93 -0
  15. opencode_arch/cli/gap_analyzer.py +107 -0
  16. opencode_arch/cli/generate.py +68 -0
  17. opencode_arch/cli/launch.py +264 -0
  18. opencode_arch/cli/main.py +360 -0
  19. opencode_arch/cli/metrics.py +186 -0
  20. opencode_arch/cli/prompts.py +20 -0
  21. opencode_arch/cli/regen_loop.py +1028 -0
  22. opencode_arch/context/__init__.py +29 -0
  23. opencode_arch/context/formatter.py +492 -0
  24. opencode_arch/context/pipeline_bridge.py +201 -0
  25. opencode_arch/extract/__init__.py +8 -0
  26. opencode_arch/extract/constraint_detector.py +398 -0
  27. opencode_arch/extract/from_artifacts.py +837 -0
  28. opencode_arch/extract/from_code.py +646 -0
  29. opencode_arch/extract/route_detector.py +400 -0
  30. opencode_arch/extract/table_parser.py +177 -0
  31. opencode_arch/learning/__init__.py +19 -0
  32. opencode_arch/learning/adapter.py +157 -0
  33. opencode_arch/learning/assessor.py +170 -0
  34. opencode_arch/learning/classifier.py +144 -0
  35. opencode_arch/learning/lessons.py +139 -0
  36. opencode_arch/learning/maintainer.py +281 -0
  37. opencode_arch/learning/patterns.py +51 -0
  38. opencode_arch/mcp/__init__.py +1 -0
  39. opencode_arch/mcp/__main__.py +8 -0
  40. opencode_arch/mcp/server.py +183 -0
  41. opencode_arch/mcp/tools/__init__.py +1 -0
  42. opencode_arch/mcp/tools/check.py +159 -0
  43. opencode_arch/mcp/tools/extract.py +107 -0
  44. opencode_arch/mcp/tools/feedback.py +65 -0
  45. opencode_arch/mcp/tools/generate.py +104 -0
  46. opencode_arch/mcp/tools/group.py +62 -0
  47. opencode_arch/mcp/tools/ingest.py +101 -0
  48. opencode_arch/mcp/tools/require.py +77 -0
  49. opencode_arch/mcp/tools/scan.py +53 -0
  50. opencode_arch/mcp/tools/slice.py +235 -0
  51. opencode_arch/mcp/tools/validate.py +59 -0
  52. opencode_arch/prompts/__init__.py +1 -0
  53. opencode_arch/prompts/regen.py +36 -0
  54. opencode_arch/runner/__init__.py +5 -0
  55. opencode_arch/runner/base.py +21 -0
  56. opencode_arch/runner/opencode.py +66 -0
  57. opencode_arch/telemetry/__init__.py +6 -0
  58. opencode_arch/telemetry/collector.py +40 -0
  59. opencode_arch/telemetry/recorder.py +12 -0
  60. opencode_arch/telemetry/store.py +537 -0
  61. opencode_arch-1.0.0.dist-info/METADATA +247 -0
  62. opencode_arch-1.0.0.dist-info/RECORD +65 -0
  63. opencode_arch-1.0.0.dist-info/WHEEL +4 -0
  64. opencode_arch-1.0.0.dist-info/entry_points.txt +2 -0
  65. opencode_arch-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """opencode-arch: Architecture extraction, generation, and validation for OpenCode."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,48 @@
1
+ """Artifact selection and registry for model-driven SE document generation."""
2
+
3
+ from opencode_arch.artifacts.selector import (
4
+ ArtifactSpec,
5
+ ARTIFACT_REGISTRY,
6
+ SUBSYSTEM_ARTIFACTS,
7
+ SubsystemInfo,
8
+ get_artifact_spec,
9
+ select_artifacts,
10
+ select_capability_detail_artifacts,
11
+ select_subsystem_artifacts,
12
+ should_decompose,
13
+ )
14
+ from opencode_arch.artifacts.templates import ArtifactTemplate, TemplateSection, TEMPLATES, get_template, API_DETAIL_TEMPLATE
15
+ from opencode_arch.artifacts.context import assemble_artifact_context, format_capability_detail_context
16
+ from opencode_arch.artifacts.diagrams import (
17
+ generate_all_diagrams,
18
+ generate_component_diagram,
19
+ generate_dependency_diagram,
20
+ generate_focused_diagram,
21
+ generate_nav_diagram,
22
+ generate_sequence_diagram,
23
+ )
24
+
25
+ __all__ = [
26
+ "API_DETAIL_TEMPLATE",
27
+ "ArtifactSpec",
28
+ "ArtifactTemplate",
29
+ "ARTIFACT_REGISTRY",
30
+ "SUBSYSTEM_ARTIFACTS",
31
+ "SubsystemInfo",
32
+ "TEMPLATES",
33
+ "TemplateSection",
34
+ "assemble_artifact_context",
35
+ "format_capability_detail_context",
36
+ "generate_all_diagrams",
37
+ "generate_component_diagram",
38
+ "generate_dependency_diagram",
39
+ "generate_focused_diagram",
40
+ "generate_nav_diagram",
41
+ "generate_sequence_diagram",
42
+ "get_artifact_spec",
43
+ "get_template",
44
+ "select_artifacts",
45
+ "select_capability_detail_artifacts",
46
+ "select_subsystem_artifacts",
47
+ "should_decompose",
48
+ ]
@@ -0,0 +1,451 @@
1
+ """Context assembler for artifact generation.
2
+
3
+ Given an artifact template + architecture model + optional manifest,
4
+ assembles the formatted context string included in the LLM prompt.
5
+
6
+ Extracts relevant model/manifest data for each template section and
7
+ formats it concisely for LLM consumption.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from opencode_arch.artifacts.diagrams import (
13
+ generate_component_diagram,
14
+ generate_dependency_diagram,
15
+ generate_sequence_diagram,
16
+ )
17
+ from opencode_arch.artifacts.templates import ArtifactTemplate
18
+ from architecture_model.core.types import ArchitectureModel
19
+
20
+
21
+ def assemble_artifact_context(
22
+ template: ArtifactTemplate,
23
+ model: ArchitectureModel,
24
+ manifest: dict | None = None,
25
+ max_tokens: int = 4000,
26
+ ) -> str:
27
+ """Assemble formatted context for artifact generation.
28
+
29
+ Returns a structured prompt string containing:
30
+ 1. System prompt from template
31
+ 2. For each section: heading + extracted data + instructions
32
+
33
+ Token budget is approximate (1 token ~ 4 chars). If total exceeds budget,
34
+ truncate section data (not instructions).
35
+ """
36
+ max_chars = max_tokens * 4
37
+
38
+ # Build header
39
+ header = f"SYSTEM: {template.system_prompt}\n"
40
+
41
+ # Build sections
42
+ sections: list[str] = []
43
+ for section in template.sections:
44
+ data = _extract_section_data(section.source, model, manifest)
45
+ section_text = (
46
+ f"\n{section.heading}\n"
47
+ f"DATA:\n{data}\n"
48
+ f"INSTRUCTIONS: {section.instructions}\n"
49
+ )
50
+ sections.append(section_text)
51
+
52
+ # Assemble full output
53
+ full_output = header + "".join(sections)
54
+
55
+ # Truncate if over budget
56
+ if len(full_output) <= max_chars:
57
+ return full_output
58
+
59
+ # Truncate strategy: rebuild with trimmed section data
60
+ # Keep header + instructions intact, trim data
61
+ remaining = max_chars - len(header)
62
+ truncated_sections: list[str] = []
63
+
64
+ for section in template.sections:
65
+ # Fixed overhead per section (heading + DATA: + INSTRUCTIONS:)
66
+ frame = (
67
+ f"\n{section.heading}\n"
68
+ f"DATA:\n"
69
+ )
70
+ tail = f"\nINSTRUCTIONS: {section.instructions}\n"
71
+ frame_cost = len(frame) + len(tail)
72
+
73
+ if remaining <= frame_cost:
74
+ # Not enough room for even the frame — skip section
75
+ break
76
+
77
+ data = _extract_section_data(section.source, model, manifest)
78
+ available_for_data = remaining - frame_cost
79
+ if len(data) > available_for_data:
80
+ data = data[:available_for_data].rstrip() + "\n[truncated]"
81
+
82
+ section_text = frame + data + tail
83
+ truncated_sections.append(section_text)
84
+ remaining -= len(section_text)
85
+
86
+ return header + "".join(truncated_sections)
87
+
88
+
89
+ def _extract_section_data(
90
+ source: str, model: ArchitectureModel, manifest: dict | None
91
+ ) -> str:
92
+ """Route to appropriate extractor based on source string."""
93
+ extractors = {
94
+ "meta": lambda: _format_meta(model),
95
+ "components": lambda: _format_components(model),
96
+ "interfaces": lambda: _format_interfaces(model),
97
+ "capabilities": lambda: _format_capabilities(model),
98
+ "behaviors": lambda: _format_behaviors(model),
99
+ "constraints": lambda: _format_constraints(model),
100
+ "layers": lambda: _format_layers(model),
101
+ "relationships": lambda: _format_relationships(model),
102
+ "manifest.tests": lambda: _format_manifest_tests(manifest),
103
+ "manifest.metrics": lambda: _format_manifest_metrics(manifest),
104
+ }
105
+
106
+ extractor = extractors.get(source)
107
+ if extractor is None:
108
+ return "No data available for this section."
109
+ return extractor()
110
+
111
+
112
+ def _format_meta(model: ArchitectureModel) -> str:
113
+ """Format project metadata."""
114
+ lines = [
115
+ f"Project: {model.meta.project}",
116
+ f"System: {model.meta.system}",
117
+ f"Schema: {model.meta.schema_version}",
118
+ ]
119
+ if model.meta.generated_at:
120
+ lines.append(f"Generated: {model.meta.generated_at}")
121
+ return "\n".join(lines)
122
+
123
+
124
+ def _format_components(model: ArchitectureModel) -> str:
125
+ """Format components list with component diagram."""
126
+ components = model.entities.components
127
+ if not components:
128
+ return "No data available for this section."
129
+
130
+ lines = []
131
+ for c in components:
132
+ line = f"- {c.id}: {c.name} [{c.kind.value}] layer={c.layer} status={c.status.value}"
133
+ if c.files:
134
+ line += f"\n files: {', '.join(c.files)}"
135
+ if c.responsibilities:
136
+ line += f"\n responsibilities: {', '.join(c.responsibilities)}"
137
+ lines.append(line)
138
+
139
+ # Append component diagram if there are enough components
140
+ if len(components) >= 2:
141
+ diagram = generate_component_diagram(model)
142
+ lines.append(f"\nDIAGRAM:\n```plantuml\n{diagram}\n```")
143
+
144
+ return "\n".join(lines)
145
+
146
+
147
+ def _format_interfaces(model: ArchitectureModel) -> str:
148
+ """Format interfaces list."""
149
+ interfaces = model.entities.interfaces
150
+ if not interfaces:
151
+ return "No data available for this section."
152
+
153
+ lines = []
154
+ for i in interfaces:
155
+ line = f"- {i.id}: {i.name} [{i.type.value}] protocol={i.protocol}"
156
+ line += f"\n provider={i.provider} consumer={i.consumer}"
157
+ line += f"\n endpoints: {len(i.endpoints)}"
158
+ lines.append(line)
159
+ return "\n".join(lines)
160
+
161
+
162
+ def _format_capabilities(model: ArchitectureModel) -> str:
163
+ """Format capabilities list."""
164
+ capabilities = model.entities.capabilities
165
+ if not capabilities:
166
+ return "No data available for this section."
167
+
168
+ lines = []
169
+ for cap in capabilities:
170
+ line = f"- {cap.id}: {cap.name} [priority={cap.priority.value}] f_block={cap.f_block}"
171
+ lines.append(line)
172
+ return "\n".join(lines)
173
+
174
+
175
+ def _format_behaviors(model: ArchitectureModel) -> str:
176
+ """Format behaviors list with sequence diagrams."""
177
+ behaviors = model.entities.behaviors
178
+ if not behaviors:
179
+ return "No data available for this section."
180
+
181
+ lines = []
182
+ for b in behaviors:
183
+ line = f"- {b.id}: {b.name} trigger={b.trigger} pattern={b.pattern.value}"
184
+ if b.steps:
185
+ line += f"\n steps: {', '.join(b.steps)}"
186
+ lines.append(line)
187
+
188
+ # Append sequence diagrams for behaviors with steps
189
+ diagrams_added = 0
190
+ for b in behaviors:
191
+ if b.steps and diagrams_added < 3: # cap at 3 to control token usage
192
+ diagram = generate_sequence_diagram(b, model)
193
+ if diagram:
194
+ lines.append(f"\nSEQUENCE ({b.name}):\n```plantuml\n{diagram}\n```")
195
+ diagrams_added += 1
196
+
197
+ return "\n".join(lines)
198
+
199
+
200
+ def _format_constraints(model: ArchitectureModel) -> str:
201
+ """Format constraints list."""
202
+ constraints = model.entities.constraints
203
+ if not constraints:
204
+ return "No data available for this section."
205
+
206
+ lines = []
207
+ for c in constraints:
208
+ line = f"- {c.id}: {c.name} [{c.type.value}] metric={c.metric} threshold={c.threshold}"
209
+ lines.append(line)
210
+ return "\n".join(lines)
211
+
212
+
213
+ def _format_layers(model: ArchitectureModel) -> str:
214
+ """Format layers list."""
215
+ layers = model.entities.layers
216
+ if not layers:
217
+ return "No data available for this section."
218
+
219
+ lines = []
220
+ for layer in layers:
221
+ tech = ", ".join(layer.technology) if layer.technology else "none"
222
+ line = f"- {layer.id}: {layer.name} [order={layer.order}] tech={tech}"
223
+ lines.append(line)
224
+ return "\n".join(lines)
225
+
226
+
227
+ def _format_relationships(model: ArchitectureModel) -> str:
228
+ """Format relationship list with dependency diagram."""
229
+ relationships = model.relationships
230
+ if not relationships:
231
+ return "No data available for this section."
232
+
233
+ lines = []
234
+ for r in relationships:
235
+ line = f"- {r.from_id} --{r.type.value}--> {r.to_id}"
236
+ lines.append(line)
237
+
238
+ # Append dependency diagram if there are relationships
239
+ diagram = generate_dependency_diagram(model)
240
+ if "@startuml" in diagram and "rectangle" in diagram:
241
+ lines.append(f"\nDIAGRAM:\n```plantuml\n{diagram}\n```")
242
+
243
+ return "\n".join(lines)
244
+
245
+
246
+ def _format_manifest_tests(manifest: dict | None) -> str:
247
+ """Format test data from manifest."""
248
+ if manifest is None or "test_files" not in manifest:
249
+ return "No data available for this section."
250
+
251
+ test_files = manifest["test_files"]
252
+ lines = [f"Test files: {len(test_files)}"]
253
+ for f in test_files:
254
+ lines.append(f"- {f}")
255
+ return "\n".join(lines)
256
+
257
+
258
+ def _format_manifest_metrics(manifest: dict | None) -> str:
259
+ """Format metrics from manifest."""
260
+ if manifest is None or "metrics" not in manifest:
261
+ return "No data available for this section."
262
+
263
+ metrics = manifest["metrics"]
264
+ lines = []
265
+ for key, value in metrics.items():
266
+ lines.append(f"- {key}: {value}")
267
+ return "\n".join(lines)
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # Per-Capability Detail Context
272
+ # ---------------------------------------------------------------------------
273
+
274
+
275
+ def format_capability_detail_context(
276
+ cap_id: str,
277
+ model: ArchitectureModel,
278
+ manifest: dict | None = None,
279
+ ) -> str:
280
+ """Assemble rich context for generating a per-capability API detail doc.
281
+
282
+ Extracts the full neighborhood of a single capability:
283
+ - The capability entity itself
284
+ - Realizing component(s) and their files/signatures/constants
285
+ - Related behaviors (steps, triggers, pre/postconditions)
286
+ - Exposed interfaces (endpoints, protocol)
287
+ - Applicable constraints
288
+ - Upstream/downstream relationships
289
+
290
+ Returns a structured text block suitable for LLM prompt injection.
291
+ """
292
+ from architecture_model.core.types import RelationType
293
+
294
+ # Find the capability
295
+ cap = None
296
+ for c in model.entities.capabilities:
297
+ if c.id == cap_id:
298
+ cap = c
299
+ break
300
+ if cap is None:
301
+ return f"Capability {cap_id} not found in model."
302
+
303
+ lines: list[str] = []
304
+
305
+ # --- Capability identity ---
306
+ lines.append("## Capability")
307
+ lines.append(f"ID: {cap.id}")
308
+ lines.append(f"Name: {cap.name}")
309
+ lines.append(f"Status: {cap.status.value}")
310
+ lines.append(f"F-Block: {cap.f_block}")
311
+ lines.append(f"Priority: {cap.priority.value}")
312
+ if cap.requirements:
313
+ lines.append(f"Requirements: {'; '.join(cap.requirements)}")
314
+
315
+ # --- Realizing components ---
316
+ realizing_comp_ids: list[str] = []
317
+ for rel in model.relationships:
318
+ if rel.type == RelationType.REALIZES and rel.to_id == cap_id:
319
+ realizing_comp_ids.append(rel.from_id)
320
+
321
+ realizing_comps = [
322
+ comp for comp in model.entities.components
323
+ if comp.id in realizing_comp_ids
324
+ ]
325
+
326
+ if realizing_comps:
327
+ lines.append("\n## Realizing Components")
328
+ for comp in realizing_comps:
329
+ lines.append(f"\n### {comp.id}: {comp.name}")
330
+ lines.append(f"Kind: {comp.kind.value}")
331
+ lines.append(f"Layer: {comp.layer}")
332
+ lines.append(f"Status: {comp.status.value}")
333
+ if comp.files:
334
+ lines.append(f"Files: {', '.join(comp.files)}")
335
+ if comp.responsibilities:
336
+ lines.append(f"Responsibilities: {'; '.join(comp.responsibilities)}")
337
+ if comp.technology:
338
+ lines.append(f"Technology: {comp.technology}")
339
+
340
+ # Signatures (enriched model v1.4)
341
+ if comp.signatures:
342
+ lines.append(f"\nFunction Signatures ({len(comp.signatures)}):")
343
+ for sig in comp.signatures:
344
+ sig_line = f" - {sig.name}({', '.join(sig.params)}) -> {sig.returns}"
345
+ if sig.body_hint:
346
+ sig_line += f" # {sig.body_hint}"
347
+ lines.append(sig_line)
348
+
349
+ # Constants (enriched model v1.4)
350
+ if comp.constants:
351
+ lines.append(f"\nConstants ({len(comp.constants)}):")
352
+ for const in comp.constants:
353
+ lines.append(f" - {const.name} = {const.value}")
354
+
355
+ # Test contracts (enriched model v1.4)
356
+ if comp.test_contracts:
357
+ lines.append(f"\nTest Contracts ({len(comp.test_contracts)}):")
358
+ for tc in comp.test_contracts[:20]: # cap for token budget
359
+ lines.append(f" - [{tc.test_file}::{tc.test_method}] {tc.assertion}")
360
+
361
+ # --- Related behaviors ---
362
+ # Find behaviors linked to realizing components via 'traces-to' or matching actor
363
+ behavior_ids: set[str] = set()
364
+ for rel in model.relationships:
365
+ if rel.type == RelationType.TRACES_TO and rel.from_id in realizing_comp_ids:
366
+ behavior_ids.add(rel.to_id)
367
+ elif rel.type == RelationType.TRACES_TO and rel.to_id in realizing_comp_ids:
368
+ behavior_ids.add(rel.from_id)
369
+
370
+ behaviors = [b for b in model.entities.behaviors if b.id in behavior_ids]
371
+ if behaviors:
372
+ lines.append("\n## Behavioral Views")
373
+ for beh in behaviors:
374
+ lines.append(f"\n### {beh.id}: {beh.name}")
375
+ lines.append(f"Trigger: {beh.trigger}")
376
+ lines.append(f"Pattern: {beh.pattern.value}")
377
+ if beh.actor:
378
+ lines.append(f"Actor: {beh.actor}")
379
+ if beh.steps:
380
+ lines.append("Steps:")
381
+ for i, step in enumerate(beh.steps, 1):
382
+ lines.append(f" {i}. {step}")
383
+
384
+ # --- Exposed interfaces ---
385
+ interface_ids: set[str] = set()
386
+ for rel in model.relationships:
387
+ if rel.type == RelationType.EXPOSES and rel.from_id in realizing_comp_ids:
388
+ interface_ids.add(rel.to_id)
389
+
390
+ interfaces = [iface for iface in model.entities.interfaces if iface.id in interface_ids]
391
+ if interfaces:
392
+ lines.append("\n## Exposed Interfaces")
393
+ for iface in interfaces:
394
+ lines.append(f"\n### {iface.id}: {iface.name}")
395
+ lines.append(f"Protocol: {iface.protocol}")
396
+ lines.append(f"Provider: {iface.provider}")
397
+ lines.append(f"Consumer: {iface.consumer}")
398
+ if iface.endpoints:
399
+ lines.append("Endpoints:")
400
+ for ep in iface.endpoints:
401
+ lines.append(f" - {ep.get('method', '?')}: {ep.get('path', '?')}")
402
+
403
+ # --- Constraints ---
404
+ constraint_ids: set[str] = set()
405
+ for rel in model.relationships:
406
+ if rel.type == RelationType.CONSTRAINED_BY:
407
+ if rel.from_id in realizing_comp_ids or rel.to_id in realizing_comp_ids:
408
+ constraint_ids.add(rel.from_id)
409
+ constraint_ids.add(rel.to_id)
410
+
411
+ constraints = [c for c in model.entities.constraints if c.id in constraint_ids]
412
+ if constraints:
413
+ lines.append("\n## Constraints")
414
+ for con in constraints:
415
+ lines.append(f"- {con.id}: {con.name} [{con.type.value}]")
416
+ lines.append(f" metric={con.metric}, threshold={con.threshold}")
417
+
418
+ # --- Dependency relationships ---
419
+ dep_lines: list[str] = []
420
+ all_related_ids = set(realizing_comp_ids) | interface_ids | behavior_ids | constraint_ids
421
+ for rel in model.relationships:
422
+ if rel.from_id in all_related_ids or rel.to_id in all_related_ids:
423
+ dep_lines.append(f" {rel.from_id} --{rel.type.value}--> {rel.to_id}")
424
+
425
+ if dep_lines:
426
+ lines.append("\n## Relationship Graph")
427
+ lines.extend(dep_lines)
428
+
429
+ # --- Manifest data for realizing files ---
430
+ if manifest and realizing_comps:
431
+ modules = manifest.get("modules", [])
432
+ comp_files = set()
433
+ for comp in realizing_comps:
434
+ comp_files.update(comp.files)
435
+
436
+ relevant_modules = [m for m in modules if m.get("file", "") in comp_files]
437
+ if relevant_modules:
438
+ lines.append("\n## Manifest Detail (AST-scanned)")
439
+ for mod in relevant_modules:
440
+ lines.append(f"\n### {mod.get('file', '?')}")
441
+ if mod.get("docstring"):
442
+ lines.append(f" Docstring: {mod['docstring'][:200]}")
443
+ if mod.get("functions"):
444
+ lines.append(f" Functions: {', '.join(mod['functions'][:15])}")
445
+ if mod.get("classes"):
446
+ for cls in mod["classes"][:5]:
447
+ cls_name = cls.get("name", "?")
448
+ methods = cls.get("methods", [])
449
+ lines.append(f" Class {cls_name}: {', '.join(methods[:10])}")
450
+
451
+ return "\n".join(lines)