sumd 0.3.4__tar.gz → 0.3.6__tar.gz

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 (33) hide show
  1. {sumd-0.3.4 → sumd-0.3.6}/CHANGELOG.md +40 -0
  2. {sumd-0.3.4 → sumd-0.3.6}/PKG-INFO +23 -18
  3. {sumd-0.3.4 → sumd-0.3.6}/README.md +21 -17
  4. {sumd-0.3.4 → sumd-0.3.6}/pyproject.toml +2 -1
  5. {sumd-0.3.4 → sumd-0.3.6}/sumd/__init__.py +1 -1
  6. {sumd-0.3.4 → sumd-0.3.6}/sumd/cli.py +73 -79
  7. {sumd-0.3.4 → sumd-0.3.6}/sumd/extractor.py +108 -86
  8. {sumd-0.3.4 → sumd-0.3.6}/sumd/parser.py +30 -23
  9. {sumd-0.3.4 → sumd-0.3.6}/sumd/pipeline.py +37 -36
  10. {sumd-0.3.4 → sumd-0.3.6}/sumd/renderer.py +125 -78
  11. {sumd-0.3.4 → sumd-0.3.6}/.gitignore +0 -0
  12. {sumd-0.3.4 → sumd-0.3.6}/LICENSE +0 -0
  13. {sumd-0.3.4 → sumd-0.3.6}/sumd/generator.py +0 -0
  14. {sumd-0.3.4 → sumd-0.3.6}/sumd/mcp_server.py +0 -0
  15. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/__init__.py +0 -0
  16. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/api_stubs.py +0 -0
  17. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/architecture.py +0 -0
  18. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/base.py +0 -0
  19. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/call_graph.py +0 -0
  20. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/code_analysis.py +0 -0
  21. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/configuration.py +0 -0
  22. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/dependencies.py +0 -0
  23. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/deployment.py +0 -0
  24. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/environment.py +0 -0
  25. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/extras.py +0 -0
  26. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/interfaces.py +0 -0
  27. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/metadata.py +0 -0
  28. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/quality.py +0 -0
  29. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/refactor_analysis.py +0 -0
  30. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/source_snippets.py +0 -0
  31. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/test_contracts.py +0 -0
  32. {sumd-0.3.4 → sumd-0.3.6}/sumd/sections/workflows.py +0 -0
  33. {sumd-0.3.4 → sumd-0.3.6}/sumd/toon_parser.py +0 -0
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.6] - 2026-04-19
11
+
12
+ ### Docs
13
+ - Update README.md
14
+
15
+ ### Other
16
+ - Update sumd/cli.py
17
+
18
+ ## [0.3.5] - 2026-04-19
19
+
20
+ ### Docs
21
+ - Update README.md
22
+ - Update SUMD.md
23
+ - Update SUMR.md
24
+ - Update docs/README.md
25
+ - Update docs/USAGE.md
26
+ - Update examples/README.md
27
+ - Update examples/basic/README.md
28
+ - Update examples/basic/sample-project/README.md
29
+ - Update examples/basic/sample-project/SUMD.md
30
+ - Update examples/integrations/README.md
31
+ - ... and 5 more files
32
+
33
+ ### Test
34
+ - Update tests/test_cli.py
35
+ - Update tests/test_mcp_server.py
36
+
37
+ ### Other
38
+ - Update Taskfile.yml
39
+ - Update examples/basic/demo.sh
40
+ - Update examples/basic/sample-project/Taskfile.yml
41
+ - Update examples/basic/sample-project/goal.yaml
42
+ - Update examples/basic/sample-project/openapi.yaml
43
+ - Update examples/basic/sample-project/project/map.toon.yaml
44
+ - Update examples/basic/sample-project/pyproject.toml
45
+ - Update examples/basic/sample-project/sumd.json
46
+ - Update examples/integrations/Dockerfile
47
+ - Update examples/integrations/docker-compose.yml
48
+ - ... and 23 more files
49
+
10
50
  ## [0.3.4] - 2026-04-19
11
51
 
12
52
  ### Docs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sumd
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: SUMD - Structured Unified Markdown Descriptor for AI-aware project documentation
5
5
  Project-URL: Homepage, https://github.com/tom/statement
6
6
  Project-URL: Repository, https://github.com/tom/statement
@@ -25,6 +25,7 @@ Provides-Extra: dev
25
25
  Requires-Dist: build; extra == 'dev'
26
26
  Requires-Dist: costs>=0.1.20; extra == 'dev'
27
27
  Requires-Dist: goal>=2.1.0; extra == 'dev'
28
+ Requires-Dist: mcp>=1.0.0; extra == 'dev'
28
29
  Requires-Dist: pfix>=0.1.60; extra == 'dev'
29
30
  Requires-Dist: pyqual>=0.1; extra == 'dev'
30
31
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
@@ -37,41 +38,45 @@ Description-Content-Type: text/markdown
37
38
 
38
39
  # SUMD
39
40
 
41
+
40
42
  ## AI Cost Tracking
41
43
 
42
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.10-yellow) ![AI Model](https://img.shields.io/badge/AI%20Model-openrouter%2Fqwen%2Fqwen3-coder-next-lightgrey)
44
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.3.6-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
45
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.40-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-13.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
43
46
 
44
- This project uses AI-generated code. Total cost: **$5.1000** with **34** AI commits.
47
+ - 🤖 **LLM usage:** $5.4000 (36 commits)
48
+ - 👤 **Human dev:** ~$1306 (13.1h @ $100/h, 30min dedup)
45
49
 
46
- Generated on 2026-04-19 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/models/openrouter/qwen/qwen3-coder-next)
50
+ Generated on 2026-04-19 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
47
51
 
48
52
  ---
49
53
 
54
+ ![Version](https://img.shields.io/badge/version-0.3.6-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
50
55
 
51
-
52
- ![Version](https://img.shields.io/badge/version-0.3.4-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
53
-
54
-
55
-
56
- SUMD is a semantic project descriptor format in Markdown that defines intent, structure, execution entry points, and mental model of a system for both humans and LLMs.
56
+ **SUMD** (Structured Unified Markdown Descriptor) is a semantic project descriptor format in Markdown.
57
+ It defines intent, structure, execution entry points, and the mental model of a system for both humans and LLMs.
57
58
 
58
59
  ## What is SUMD?
59
60
 
60
- SUMD (Structured Unified Markdown Descriptor) is a lightweight structured markdown format for AI-aware project descriptions. It serves as a single source of truth for project documentation, optimized for both human readability and LLM context injection.
61
+ SUMD is a lightweight structured markdown format that serves as a **single source of truth** for project documentation optimised for both human readability and LLM context injection.
62
+
63
+ Think of it as a machine-readable README: a file an AI agent can parse, reason over, and act upon.
61
64
 
62
65
  ### Purpose
63
66
 
64
- - **Project descriptor**: Defines API, CLI, workflows, endpoints, and system architecture
65
- - **AI-optimized**: Structured for LLM consumption and automation tools
66
- - **Lightweight manifest**: Bridges the gap between README, spec, and configuration files
67
- - **Context injection**: Provides structured context for AI agents and tools
67
+ - **Project descriptor** defines API, CLI, workflows, endpoints, and system architecture
68
+ - **AI context feed** — structured for LLM consumption: inject `SUMD.md` into any prompt to give the model full project understanding
69
+ - **Lightweight manifest** bridges the gap between README, openapi spec, and configuration files
70
+ - **Automation anchor** drives `sumd scan`, `sumd lint`, `sumd scaffold`, CI pipelines
68
71
 
69
72
  ### Use Cases
70
73
 
71
- - Project documentation and specification
72
- - Input for LLM context injection
74
+ - Generating structured documentation from source code
75
+ - Single-file project context for ChatGPT, Claude, Gemini, or local LLMs
76
+ - LLM agent memory and tool context (via MCP server)
77
+ - Input for testql scenario scaffolding
78
+ - API and CLI contract documentation
73
79
  - CI/CD workflow descriptions
74
- - API and CLI mapping
75
80
  - Structural project manifest
76
81
 
77
82
  ## Installation
@@ -1,40 +1,44 @@
1
1
  # SUMD
2
2
 
3
+
3
4
  ## AI Cost Tracking
4
5
 
5
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.10-yellow) ![AI Model](https://img.shields.io/badge/AI%20Model-openrouter%2Fqwen%2Fqwen3-coder-next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.3.6-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$5.40-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-13.1h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
8
 
7
- This project uses AI-generated code. Total cost: **$5.1000** with **34** AI commits.
9
+ - 🤖 **LLM usage:** $5.4000 (36 commits)
10
+ - 👤 **Human dev:** ~$1306 (13.1h @ $100/h, 30min dedup)
8
11
 
9
- Generated on 2026-04-19 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/models/openrouter/qwen/qwen3-coder-next)
12
+ Generated on 2026-04-19 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
10
13
 
11
14
  ---
12
15
 
16
+ ![Version](https://img.shields.io/badge/version-0.3.6-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
13
17
 
14
-
15
- ![Version](https://img.shields.io/badge/version-0.3.4-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
-
17
-
18
-
19
- SUMD is a semantic project descriptor format in Markdown that defines intent, structure, execution entry points, and mental model of a system for both humans and LLMs.
18
+ **SUMD** (Structured Unified Markdown Descriptor) is a semantic project descriptor format in Markdown.
19
+ It defines intent, structure, execution entry points, and the mental model of a system for both humans and LLMs.
20
20
 
21
21
  ## What is SUMD?
22
22
 
23
- SUMD (Structured Unified Markdown Descriptor) is a lightweight structured markdown format for AI-aware project descriptions. It serves as a single source of truth for project documentation, optimized for both human readability and LLM context injection.
23
+ SUMD is a lightweight structured markdown format that serves as a **single source of truth** for project documentation optimised for both human readability and LLM context injection.
24
+
25
+ Think of it as a machine-readable README: a file an AI agent can parse, reason over, and act upon.
24
26
 
25
27
  ### Purpose
26
28
 
27
- - **Project descriptor**: Defines API, CLI, workflows, endpoints, and system architecture
28
- - **AI-optimized**: Structured for LLM consumption and automation tools
29
- - **Lightweight manifest**: Bridges the gap between README, spec, and configuration files
30
- - **Context injection**: Provides structured context for AI agents and tools
29
+ - **Project descriptor** defines API, CLI, workflows, endpoints, and system architecture
30
+ - **AI context feed** — structured for LLM consumption: inject `SUMD.md` into any prompt to give the model full project understanding
31
+ - **Lightweight manifest** bridges the gap between README, openapi spec, and configuration files
32
+ - **Automation anchor** drives `sumd scan`, `sumd lint`, `sumd scaffold`, CI pipelines
31
33
 
32
34
  ### Use Cases
33
35
 
34
- - Project documentation and specification
35
- - Input for LLM context injection
36
+ - Generating structured documentation from source code
37
+ - Single-file project context for ChatGPT, Claude, Gemini, or local LLMs
38
+ - LLM agent memory and tool context (via MCP server)
39
+ - Input for testql scenario scaffolding
40
+ - API and CLI contract documentation
36
41
  - CI/CD workflow descriptions
37
- - API and CLI mapping
38
42
  - Structural project manifest
39
43
 
40
44
  ## Installation
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sumd"
7
- version = "0.3.4"
7
+ version = "0.3.6"
8
8
  description = "SUMD - Structured Unified Markdown Descriptor for AI-aware project documentation"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -44,6 +44,7 @@ dev = [
44
44
  "goal>=2.1.0",
45
45
  "costs>=0.1.20",
46
46
  "pfix>=0.1.60",
47
+ "mcp>=1.0.0",
47
48
  ]
48
49
 
49
50
  [tool.pytest.ini_options]
@@ -4,7 +4,7 @@ SUMD is a semantic project descriptor format in Markdown that defines intent,
4
4
  structure, execution entry points, and mental model of a system for both humans and LLMs.
5
5
  """
6
6
 
7
- __version__ = "0.3.4"
7
+ __version__ = "0.3.6"
8
8
 
9
9
  from sumd.parser import (
10
10
  SUMDDocument,
@@ -223,29 +223,74 @@ _SKIP_DIRS = {
223
223
  }
224
224
 
225
225
 
226
- def _detect_projects(workspace: Path, max_depth: int | None = None) -> list[Path]:
227
- """Return sorted list of subdirectories containing pyproject.toml (recursive)."""
228
- projects: list[Path] = []
229
-
230
- def _walk(path: Path, depth: int) -> None:
231
- if max_depth is not None and depth > max_depth:
232
- return
226
+ def _walk_projects(
227
+ path: Path, projects: list[Path], max_depth: int | None, depth: int
228
+ ) -> None:
229
+ """Recursively collect project directories (containing pyproject.toml)."""
230
+ if max_depth is not None and depth > max_depth:
231
+ return
232
+ try:
233
+ entries = sorted(path.iterdir(), key=lambda p: p.name)
234
+ except PermissionError:
235
+ return
236
+ for d in entries:
237
+ if not d.is_dir() or d.name.startswith(".") or d.name in _SKIP_DIRS:
238
+ continue
233
239
  try:
234
- entries = sorted(path.iterdir(), key=lambda p: p.name)
235
- except PermissionError:
236
- return
237
- for d in entries:
238
- if not d.is_dir() or d.name.startswith(".") or d.name in _SKIP_DIRS:
239
- continue
240
240
  if (d / "pyproject.toml").exists():
241
241
  projects.append(d)
242
242
  else:
243
- _walk(d, depth + 1)
243
+ _walk_projects(d, projects, max_depth, depth + 1)
244
+ except PermissionError:
245
+ continue
244
246
 
245
- _walk(workspace, 0)
247
+
248
+ def _detect_projects(workspace: Path, max_depth: int | None = None) -> list[Path]:
249
+ """Return sorted list of subdirectories containing pyproject.toml (recursive)."""
250
+ projects: list[Path] = []
251
+ _walk_projects(workspace, projects, max_depth, 0)
246
252
  return projects
247
253
 
248
254
 
255
+ def _ensure_venv(tools_dir: Path, venv_dir: Path, tool_list: list[str]) -> None:
256
+ """Create virtual env and install tools if not already present."""
257
+ if venv_dir.exists():
258
+ return
259
+ tools_dir.mkdir(exist_ok=True)
260
+ subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], capture_output=True)
261
+ pip_path = venv_dir / "bin" / "pip"
262
+ if not pip_path.exists():
263
+ pip_path = venv_dir / "Scripts" / "pip.exe"
264
+ for pkg in tool_list:
265
+ subprocess.run([str(pip_path), "install", "-q", pkg], capture_output=True)
266
+
267
+
268
+ def _tool_bin(bin_dir: Path, name: str) -> Path:
269
+ """Return path to *name* executable, preferring non-.exe variant."""
270
+ plain = bin_dir / name
271
+ return plain if plain.exists() else bin_dir / f"{name}.exe"
272
+
273
+
274
+ # Tool-specific CLI argument templates
275
+ _TOOL_ARGS: dict[str, list[str]] = {
276
+ "code2llm": ["{proj_dir}", "-f", "all", "-o", "{out}", "--no-chunk"],
277
+ "redup": ["scan", "{proj_dir}", "--format", "toon", "--output", "{out}"],
278
+ "vallm": ["batch", "{proj_dir}", "--recursive", "--format", "toon", "--output", "{out}"],
279
+ }
280
+
281
+
282
+ def _run_one_tool(tool: str, bin_dir: Path, proj_dir: Path, project_output: Path) -> None:
283
+ """Run a single analysis tool if its arg template is known."""
284
+ template = _TOOL_ARGS.get(tool)
285
+ if template is None:
286
+ return
287
+ args = [
288
+ a.replace("{proj_dir}", str(proj_dir)).replace("{out}", str(project_output))
289
+ for a in template
290
+ ]
291
+ subprocess.run([str(_tool_bin(bin_dir, tool))] + args, capture_output=True, cwd=str(proj_dir))
292
+
293
+
249
294
  def _run_analysis_tools(
250
295
  proj_dir: Path,
251
296
  tool_list: list[str],
@@ -261,16 +306,7 @@ def _run_analysis_tools(
261
306
  venv_dir = tools_dir / "venv"
262
307
  project_output = proj_dir / "project"
263
308
 
264
- if not venv_dir.exists():
265
- tools_dir.mkdir(exist_ok=True)
266
- subprocess.run(
267
- [sys.executable, "-m", "venv", str(venv_dir)], capture_output=True
268
- )
269
- pip_path = venv_dir / "bin" / "pip"
270
- if not pip_path.exists():
271
- pip_path = venv_dir / "Scripts" / "pip.exe"
272
- for pkg in tool_list:
273
- subprocess.run([str(pip_path), "install", "-q", pkg], capture_output=True)
309
+ _ensure_venv(tools_dir, venv_dir, tool_list)
274
310
 
275
311
  bin_dir = venv_dir / "bin"
276
312
  if not bin_dir.exists():
@@ -278,56 +314,9 @@ def _run_analysis_tools(
278
314
 
279
315
  project_output.mkdir(exist_ok=True)
280
316
 
281
- if "code2llm" in tool_list and "code2llm" not in skip_tools:
282
- code2llm = bin_dir / (
283
- "code2llm.exe" if not (bin_dir / "code2llm").exists() else "code2llm"
284
- )
285
- subprocess.run(
286
- [
287
- str(code2llm),
288
- str(proj_dir),
289
- "-f",
290
- "all",
291
- "-o",
292
- str(project_output),
293
- "--no-chunk",
294
- ],
295
- capture_output=True,
296
- cwd=str(proj_dir),
297
- )
298
-
299
- if "redup" in tool_list and "redup" not in skip_tools:
300
- redup = bin_dir / ("redup.exe" if not (bin_dir / "redup").exists() else "redup")
301
- subprocess.run(
302
- [
303
- str(redup),
304
- "scan",
305
- str(proj_dir),
306
- "--format",
307
- "toon",
308
- "--output",
309
- str(project_output),
310
- ],
311
- capture_output=True,
312
- cwd=str(proj_dir),
313
- )
314
-
315
- if "vallm" in tool_list and "vallm" not in skip_tools:
316
- vallm = bin_dir / ("vallm.exe" if not (bin_dir / "vallm").exists() else "vallm")
317
- subprocess.run(
318
- [
319
- str(vallm),
320
- "batch",
321
- str(proj_dir),
322
- "--recursive",
323
- "--format",
324
- "toon",
325
- "--output",
326
- str(project_output),
327
- ],
328
- capture_output=True,
329
- cwd=str(proj_dir),
330
- )
317
+ for tool in tool_list:
318
+ if tool not in skip_tools:
319
+ _run_one_tool(tool, bin_dir, proj_dir, project_output)
331
320
 
332
321
 
333
322
  def _export_sumd_json(proj_dir: Path, doc) -> None:
@@ -370,6 +359,14 @@ def _render_write_validate(
370
359
  return doc, md_issues, cb_errors, cb_warnings, sources
371
360
 
372
361
 
362
+ def _echo_scan_result(proj_dir: Path, doc, sources: list, cb_warnings: list) -> None:
363
+ """Print success line for a scanned project."""
364
+ warn_str = f" \u26a0 {len(cb_warnings)} warnings" if cb_warnings else ""
365
+ click.echo(
366
+ f" \u2705 {proj_dir.name:<18} {'ok':<10} {len(doc.sections):<10} {', '.join(sources)}{warn_str}"
367
+ )
368
+
369
+
373
370
  def _scan_one_project(
374
371
  proj_dir: Path,
375
372
  fix: bool,
@@ -405,10 +402,7 @@ def _scan_one_project(
405
402
  click.echo(f" \u2193 {e}")
406
403
  return {"status": "INVALID", "errors": all_errors, "path": str(sumd_path)}
407
404
 
408
- warn_str = f" \u26a0 {len(cb_warnings)} warnings" if cb_warnings else ""
409
- click.echo(
410
- f" \u2705 {proj_dir.name:<18} {'ok':<10} {len(doc.sections):<10} {', '.join(sources)}{warn_str}"
411
- )
405
+ _echo_scan_result(proj_dir, doc, sources, cb_warnings)
412
406
 
413
407
  if export_json:
414
408
  _export_sumd_json(proj_dir, doc)
@@ -57,6 +57,17 @@ def extract_pyproject(proj_dir: Path) -> dict[str, Any]:
57
57
  return {}
58
58
 
59
59
 
60
+ def _first_task_cmd(cmds: list) -> str:
61
+ """Extract the first command string from a task's cmds list."""
62
+ if not cmds:
63
+ return ""
64
+ if isinstance(cmds[0], str):
65
+ return cmds[0]
66
+ if isinstance(cmds[0], dict):
67
+ return cmds[0].get("task", cmds[0].get("cmd", ""))
68
+ return ""
69
+
70
+
60
71
  def extract_taskfile(proj_dir: Path) -> list[dict[str, str]]:
61
72
  taskfile_path = proj_dir / "Taskfile.yml"
62
73
  if not taskfile_path.exists():
@@ -69,18 +80,40 @@ def extract_taskfile(proj_dir: Path) -> list[dict[str, str]]:
69
80
  if not isinstance(body, dict):
70
81
  continue
71
82
  desc = body.get("desc", "")
72
- cmds = body.get("cmds", [])
73
- cmd = ""
74
- if cmds and isinstance(cmds[0], str):
75
- cmd = cmds[0]
76
- elif cmds and isinstance(cmds[0], dict):
77
- cmd = cmds[0].get("task", cmds[0].get("cmd", ""))
83
+ cmd = _first_task_cmd(body.get("cmds", []))
78
84
  result.append({"name": task_name, "desc": desc, "cmd": cmd})
79
85
  return result
80
86
  except Exception:
81
87
  return []
82
88
 
83
89
 
90
+ def _parse_openapi_endpoints(paths: dict) -> list[dict]:
91
+ """Extract endpoint dicts from the paths section of an OpenAPI spec."""
92
+ _HTTP_METHODS = {"get", "post", "put", "delete", "patch"}
93
+ endpoints: list[dict] = []
94
+ seen: set[tuple] = set()
95
+ for path, methods in paths.items():
96
+ if not isinstance(methods, dict) or "://" in path:
97
+ continue
98
+ for method, spec in methods.items():
99
+ if method not in _HTTP_METHODS or not isinstance(spec, dict):
100
+ continue
101
+ key = (method.upper(), path)
102
+ if key in seen:
103
+ continue
104
+ seen.add(key)
105
+ endpoints.append(
106
+ {
107
+ "method": method.upper(),
108
+ "path": path,
109
+ "operationId": spec.get("operationId", ""),
110
+ "summary": spec.get("summary", ""),
111
+ "tags": spec.get("tags", []),
112
+ }
113
+ )
114
+ return endpoints
115
+
116
+
84
117
  def extract_openapi(proj_dir: Path) -> dict[str, Any]:
85
118
  openapi_path = proj_dir / "openapi.yaml"
86
119
  if not openapi_path.exists():
@@ -89,35 +122,12 @@ def extract_openapi(proj_dir: Path) -> dict[str, Any]:
89
122
  data = yaml.safe_load(openapi_path.read_text(encoding="utf-8"))
90
123
  info = data.get("info", {})
91
124
  paths = data.get("paths", {}) or {}
92
- endpoints = []
93
- seen: set[tuple] = set()
94
- for path, methods in paths.items():
95
- if not isinstance(methods, dict) or "://" in path:
96
- continue
97
- for method, spec in methods.items():
98
- if method not in ("get", "post", "put", "delete", "patch"):
99
- continue
100
- if not isinstance(spec, dict):
101
- continue
102
- key = (method.upper(), path)
103
- if key in seen:
104
- continue
105
- seen.add(key)
106
- endpoints.append(
107
- {
108
- "method": method.upper(),
109
- "path": path,
110
- "operationId": spec.get("operationId", ""),
111
- "summary": spec.get("summary", ""),
112
- "tags": spec.get("tags", []),
113
- }
114
- )
115
125
  schemas = list((data.get("components", {}) or {}).get("schemas", {}).keys())
116
126
  return {
117
127
  "title": info.get("title", ""),
118
128
  "version": info.get("version", ""),
119
129
  "description": info.get("description", ""),
120
- "endpoints": endpoints,
130
+ "endpoints": _parse_openapi_endpoints(paths),
121
131
  "schemas": schemas,
122
132
  }
123
133
  except Exception:
@@ -369,6 +379,22 @@ def extract_env(proj_dir: Path) -> list[dict[str, str]]:
369
379
  return vars_
370
380
 
371
381
 
382
+ def _parse_dockerfile_line(line: str, parsed: dict) -> None:
383
+ """Update *parsed* dict in-place with one Dockerfile instruction."""
384
+ upper = line.upper()
385
+ if upper.startswith("FROM ") and not parsed["from"]:
386
+ parsed["from"] = line[5:].strip()
387
+ elif upper.startswith("EXPOSE "):
388
+ parsed["ports"].extend(line[7:].strip().split())
389
+ elif upper.startswith("ENTRYPOINT "):
390
+ parsed["entrypoint"] = line[11:].strip()
391
+ elif upper.startswith("CMD "):
392
+ parsed["cmd"] = line[4:].strip()
393
+ elif upper.startswith("LABEL "):
394
+ for kv in re.findall(r'(\w[\w.-]*)=("(?:[^"\\]|\\.)*"|\S+)', line[6:]):
395
+ parsed["labels"][kv[0]] = kv[1].strip('"')
396
+
397
+
372
398
  def extract_dockerfile(proj_dir: Path) -> dict[str, Any]:
373
399
  """Parse Dockerfile — base image, exposed ports, entrypoint, labels."""
374
400
  for candidate in (proj_dir / "Dockerfile", proj_dir / "docker" / "Dockerfile"):
@@ -378,29 +404,14 @@ def extract_dockerfile(proj_dir: Path) -> dict[str, Any]:
378
404
  else:
379
405
  return {}
380
406
  content = dockerfile_path.read_text(encoding="utf-8")
381
- from_image = ""
382
- ports: list[str] = []
383
- entrypoint = ""
384
- cmd = ""
385
- labels: dict[str, str] = {}
407
+ parsed: dict = {"from": "", "ports": [], "entrypoint": "", "cmd": "", "labels": {}}
386
408
  for line in content.splitlines():
387
- line = line.strip()
388
- if line.upper().startswith("FROM ") and not from_image:
389
- from_image = line[5:].strip()
390
- elif line.upper().startswith("EXPOSE "):
391
- ports.extend(line[7:].strip().split())
392
- elif line.upper().startswith("ENTRYPOINT "):
393
- entrypoint = line[11:].strip()
394
- elif line.upper().startswith("CMD "):
395
- cmd = line[4:].strip()
396
- elif line.upper().startswith("LABEL "):
397
- for kv in re.findall(r'(\w[\w.-]*)=("(?:[^"\\]|\\.)*"|\S+)', line[6:]):
398
- labels[kv[0]] = kv[1].strip('"')
409
+ _parse_dockerfile_line(line.strip(), parsed)
399
410
  return {
400
- "from": from_image,
401
- "ports": ports,
402
- "entrypoint": entrypoint or cmd,
403
- "labels": labels,
411
+ "from": parsed["from"],
412
+ "ports": parsed["ports"],
413
+ "entrypoint": parsed["entrypoint"] or parsed["cmd"],
414
+ "labels": parsed["labels"],
404
415
  }
405
416
 
406
417
 
@@ -586,6 +597,18 @@ def _analyse_py_top_funcs(
586
597
  return exports, funcs
587
598
 
588
599
 
600
+ def _analyse_class_methods(node: ast.ClassDef, radon_cc: dict) -> list[dict]:
601
+ """Extract method signatures and metrics from a class node."""
602
+ methods: list[dict] = []
603
+ for item in ast.iter_child_nodes(node):
604
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
605
+ margs = [a.arg for a in item.args.args if a.arg != "self"]
606
+ cc = radon_cc.get(item.name) or _cc_estimate(item)
607
+ fo = _fan_out(item)
608
+ methods.append({"name": item.name, "args": margs, "cc": cc, "fan": fo})
609
+ return methods
610
+
611
+
589
612
  def _analyse_py_top_classes(
590
613
  tree: ast.AST, radon_cc: dict[str, int]
591
614
  ) -> tuple[list[str], list[dict]]:
@@ -595,19 +618,11 @@ def _analyse_py_top_classes(
595
618
  for node in ast.iter_child_nodes(tree):
596
619
  if not isinstance(node, ast.ClassDef):
597
620
  continue
598
- cname = node.name
599
- exports.append(cname)
600
- methods: list[dict] = []
601
- for item in ast.iter_child_nodes(node):
602
- if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
603
- margs = [a.arg for a in item.args.args if a.arg != "self"]
604
- cc = radon_cc.get(item.name) or _cc_estimate(item)
605
- fo = _fan_out(item)
606
- methods.append({"name": item.name, "args": margs, "cc": cc, "fan": fo})
607
- docline = ""
608
- if (ast.get_docstring(node) or "").strip():
609
- docline = " # " + (ast.get_docstring(node) or "").splitlines()[0][:60]
610
- classes.append({"name": cname, "methods": methods, "doc": docline})
621
+ exports.append(node.name)
622
+ methods = _analyse_class_methods(node, radon_cc)
623
+ doc = (ast.get_docstring(node) or "").strip()
624
+ docline = (" # " + doc.splitlines()[0][:60]) if doc else ""
625
+ classes.append({"name": node.name, "methods": methods, "doc": docline})
611
626
  return exports, classes
612
627
 
613
628
 
@@ -629,6 +644,14 @@ def _analyse_py_module(path: Path) -> dict[str, Any]:
629
644
  }
630
645
 
631
646
 
647
+ def _is_map_ignored_path(p: Path) -> bool:
648
+ """Return True if *p* (relative) contains an ignored directory segment."""
649
+ for part in p.parts:
650
+ if part in _IGNORE_DIRS or part.endswith(".egg-info"):
651
+ return True
652
+ return False
653
+
654
+
632
655
  def _collect_map_files(
633
656
  proj_dir: Path,
634
657
  ) -> tuple[dict[str, int], list[tuple[Path, int, str]]]:
@@ -636,14 +659,8 @@ def _collect_map_files(
636
659
  lang_counts: dict[str, int] = {}
637
660
  modules: list[tuple[Path, int, str]] = [] # (path, lines, lang)
638
661
 
639
- def _should_skip(p: Path) -> bool:
640
- for part in p.parts:
641
- if part in _IGNORE_DIRS or part.endswith(".egg-info"):
642
- return True
643
- return False
644
-
645
662
  for f in sorted(proj_dir.rglob("*")):
646
- if not f.is_file() or _should_skip(f.relative_to(proj_dir)):
663
+ if not f.is_file() or _is_map_ignored_path(f.relative_to(proj_dir)):
647
664
  continue
648
665
  lang = _lang_of(f)
649
666
  if lang == "other" or lang not in _SOURCE_LANGS:
@@ -709,6 +726,25 @@ def _map_cc_stats(
709
726
  return avg_cc, critical, alerts, hotspots
710
727
 
711
728
 
729
+ def _render_py_module_detail(rel: Path, info: dict, a) -> None:
730
+ """Append detail lines for one Python module in map.toon format."""
731
+ a(f" {rel}:")
732
+ if info["exports"]:
733
+ a(f" e: {','.join(info['exports'])}")
734
+ for cls in info["classes"]:
735
+ method_summary = ",".join(
736
+ f"{m['name']}({len(m['args'])})" for m in cls["methods"]
737
+ )
738
+ doc = cls["doc"]
739
+ if method_summary:
740
+ a(f" {cls['name']}: {method_summary}{doc}")
741
+ else:
742
+ a(f" {cls['name']}:{doc}")
743
+ for f in info["funcs"]:
744
+ sig = ";".join(f["args"]) if f["args"] else ""
745
+ a(f" {f['name']}({sig})")
746
+
747
+
712
748
  def generate_map_toon(proj_dir: Path) -> str:
713
749
  """Generate project/map.toon.yaml content for proj_dir."""
714
750
  proj_dir = proj_dir.resolve()
@@ -755,21 +791,7 @@ def generate_map_toon(proj_dir: Path) -> str:
755
791
 
756
792
  a("D:")
757
793
  for rel, info in py_modules:
758
- a(f" {rel}:")
759
- if info["exports"]:
760
- a(f" e: {','.join(info['exports'])}")
761
- for cls in info["classes"]:
762
- method_summary = ",".join(
763
- f"{m['name']}({len(m['args'])})" for m in cls["methods"]
764
- )
765
- doc = cls["doc"]
766
- if method_summary:
767
- a(f" {cls['name']}: {method_summary}{doc}")
768
- else:
769
- a(f" {cls['name']}:{doc}")
770
- for f in info["funcs"]:
771
- sig = ";".join(f["args"]) if f["args"] else ""
772
- a(f" {f['name']}({sig})")
794
+ _render_py_module_detail(rel, info, a)
773
795
 
774
796
  return "\n".join(L) + "\n"
775
797
 
@@ -353,6 +353,35 @@ _MARKPACT_REQUIRED_ATTRS = {
353
353
  }
354
354
 
355
355
 
356
+ def _validate_markpact_meta(
357
+ mp: re.Match, line_no: int, lang: str, meta: str, issues: list
358
+ ) -> None:
359
+ """Check markpact annotation kind and required attributes."""
360
+ kind = mp.group("kind")
361
+ attrs = mp.group("attrs") or ""
362
+ if kind not in _VALID_MARKPACT_KINDS:
363
+ issues.append(
364
+ CodeBlockIssue(
365
+ line_no,
366
+ lang,
367
+ "error",
368
+ f"unknown markpact kind {kind!r} (valid: {', '.join(sorted(_VALID_MARKPACT_KINDS))})",
369
+ meta,
370
+ )
371
+ )
372
+ req_attr = _MARKPACT_REQUIRED_ATTRS.get(kind)
373
+ if req_attr and f"{req_attr}=" not in attrs:
374
+ issues.append(
375
+ CodeBlockIssue(
376
+ line_no,
377
+ lang,
378
+ "error",
379
+ f"markpact:{kind} requires {req_attr}=... but got: {attrs!r}",
380
+ meta,
381
+ )
382
+ )
383
+
384
+
356
385
  def validate_codeblocks(content: str, source: str = "SUMD.md") -> list[CodeBlockIssue]:
357
386
  """Validate all fenced code blocks in *content*.
358
387
 
@@ -373,29 +402,7 @@ def validate_codeblocks(content: str, source: str = "SUMD.md") -> list[CodeBlock
373
402
  # ── markpact annotation checks ──────────────────────────────────
374
403
  mp = _MARKPACT_META_RE.search(meta)
375
404
  if mp:
376
- kind = mp.group("kind")
377
- attrs = mp.group("attrs") or ""
378
- if kind not in _VALID_MARKPACT_KINDS:
379
- issues.append(
380
- CodeBlockIssue(
381
- line_no,
382
- lang,
383
- "error",
384
- f"unknown markpact kind {kind!r} (valid: {', '.join(sorted(_VALID_MARKPACT_KINDS))})",
385
- meta,
386
- )
387
- )
388
- req_attr = _MARKPACT_REQUIRED_ATTRS.get(kind)
389
- if req_attr and f"{req_attr}=" not in attrs:
390
- issues.append(
391
- CodeBlockIssue(
392
- line_no,
393
- lang,
394
- "error",
395
- f"markpact:{kind} requires {req_attr}=... but got: {attrs!r}",
396
- meta,
397
- )
398
- )
405
+ _validate_markpact_meta(mp, line_no, lang, meta, issues)
399
406
 
400
407
  # ── body emptiness ──────────────────────────────────────────────
401
408
  if not body:
@@ -75,6 +75,25 @@ def _refresh_map_toon(proj_dir: Path) -> None:
75
75
  pass # non-fatal — embed will use the previous file if it exists
76
76
 
77
77
 
78
+ def _find_tools_bin_dir(proj_dir: Path) -> Path | None:
79
+ """Return the venv bin directory for .sumd-tools, or None if not installed."""
80
+ base = proj_dir / ".sumd-tools" / "venv"
81
+ for subdir in ("bin", "Scripts"):
82
+ candidate = base / subdir
83
+ if candidate.exists():
84
+ return candidate
85
+ return None
86
+
87
+
88
+ def _run_tool_if_present(bin_dir: Path, name: str, args: list, proj_dir: Path) -> None:
89
+ """Run *name* executable with *args* if it exists in *bin_dir*."""
90
+ exe = bin_dir / name
91
+ if not exe.exists():
92
+ exe = bin_dir / f"{name}.exe"
93
+ if exe.exists():
94
+ subprocess.run([str(exe)] + args, capture_output=True, cwd=str(proj_dir))
95
+
96
+
78
97
  def _refresh_analysis_files(proj_dir: Path, profile: str) -> None:
79
98
  """Run only the external tools required for the given profile.
80
99
 
@@ -88,51 +107,33 @@ def _refresh_analysis_files(proj_dir: Path, profile: str) -> None:
88
107
  if not tools_needed:
89
108
  return
90
109
 
91
- bin_dir = proj_dir / ".sumd-tools" / "venv" / "bin"
92
- if not bin_dir.exists():
93
- bin_dir = proj_dir / ".sumd-tools" / "venv" / "Scripts"
94
- if not bin_dir.exists():
110
+ bin_dir = _find_tools_bin_dir(proj_dir)
111
+ if not bin_dir:
95
112
  return # tools not installed — skip silently
96
113
 
97
114
  project_output = proj_dir / "project"
98
115
  project_output.mkdir(parents=True, exist_ok=True)
99
116
 
100
- def _exe(name: str):
101
- p = bin_dir / name
102
- if not p.exists():
103
- p = bin_dir / f"{name}.exe"
104
- return p if p.exists() else None
105
-
106
117
  try:
107
- # code2llm generates calls.toon, analysis.toon, evolution.toon in one run
108
118
  if "code2llm" in tools_needed:
109
- exe = _exe("code2llm")
110
- if exe:
111
- subprocess.run(
112
- [str(exe), "./", "-f", "toon", "-o", str(project_output), "--no-chunk"],
113
- capture_output=True,
114
- cwd=str(proj_dir),
115
- )
116
-
119
+ _run_tool_if_present(
120
+ bin_dir, "code2llm",
121
+ ["./", "-f", "toon", "-o", str(project_output), "--no-chunk"],
122
+ proj_dir,
123
+ )
117
124
  if "redup" in tools_needed:
118
- exe = _exe("redup")
119
- if exe:
120
- subprocess.run(
121
- [str(exe), "scan", str(proj_dir),
122
- "--format", "toon", "--output", str(project_output)],
123
- capture_output=True,
124
- cwd=str(proj_dir),
125
- )
126
-
125
+ _run_tool_if_present(
126
+ bin_dir, "redup",
127
+ ["scan", str(proj_dir), "--format", "toon", "--output", str(project_output)],
128
+ proj_dir,
129
+ )
127
130
  if "vallm" in tools_needed:
128
- exe = _exe("vallm")
129
- if exe:
130
- subprocess.run(
131
- [str(exe), "batch", str(proj_dir), "--recursive",
132
- "--format", "toon", "--output", str(project_output)],
133
- capture_output=True,
134
- cwd=str(proj_dir),
135
- )
131
+ _run_tool_if_present(
132
+ bin_dir, "vallm",
133
+ ["batch", str(proj_dir), "--recursive",
134
+ "--format", "toon", "--output", str(project_output)],
135
+ proj_dir,
136
+ )
136
137
  except Exception: # noqa: BLE001
137
138
  pass # non-fatal
138
139
 
@@ -453,20 +453,25 @@ def _render_deployment_reqs(reqs: list, L: list[str]) -> None:
453
453
  a("")
454
454
 
455
455
 
456
+ def _render_dockerfile_info(dockerfile: dict, a) -> None:
457
+ """Append Dockerfile section lines to output via *a*."""
458
+ a("### Docker")
459
+ a("")
460
+ a(f"- **base image**: `{dockerfile['from']}`")
461
+ if dockerfile["ports"]:
462
+ a(f"- **expose**: {', '.join(f'`{p}`' for p in dockerfile['ports'])}")
463
+ if dockerfile["entrypoint"]:
464
+ a(f"- **entrypoint**: `{dockerfile['entrypoint']}`")
465
+ if dockerfile["labels"]:
466
+ for k, v in dockerfile["labels"].items():
467
+ a(f"- **label** `{k}`: {v}")
468
+ a("")
469
+
470
+
456
471
  def _render_deployment_docker(dockerfile: dict, compose: dict, L: list[str]) -> None:
457
472
  a = L.append
458
473
  if dockerfile:
459
- a("### Docker")
460
- a("")
461
- a(f"- **base image**: `{dockerfile['from']}`")
462
- if dockerfile["ports"]:
463
- a(f"- **expose**: {', '.join(f'`{p}`' for p in dockerfile['ports'])}")
464
- if dockerfile["entrypoint"]:
465
- a(f"- **entrypoint**: `{dockerfile['entrypoint']}`")
466
- if dockerfile["labels"]:
467
- for k, v in dockerfile["labels"].items():
468
- a(f"- **label** `{k}`: {v}")
469
- a("")
474
+ _render_dockerfile_info(dockerfile, a)
470
475
  if compose.get("services"):
471
476
  a(f"### Docker Compose (`{compose['file']}`)")
472
477
  a("")
@@ -495,35 +500,45 @@ def _render_deployment(
495
500
  return L
496
501
 
497
502
 
503
+ def _render_makefile_targets(makefile: list, a) -> None:
504
+ """Append Makefile targets section via *a*."""
505
+ a("## Makefile Targets")
506
+ a("")
507
+ for t in makefile:
508
+ desc = f" — {t['desc']}" if t["desc"] else ""
509
+ a(f"- `{t['target']}`{desc}")
510
+ a("")
511
+
512
+
513
+ def _render_pkg_json_scripts(pkg_json: dict, a) -> None:
514
+ """Append Node.js scripts section via *a*."""
515
+ a("## Node.js Scripts (`package.json`)")
516
+ a("")
517
+ if pkg_json.get("description"):
518
+ a(pkg_json["description"])
519
+ a("")
520
+ for script, cmd in pkg_json["scripts"].items():
521
+ a(f"- `npm run {script}` — `{cmd}`")
522
+ a("")
523
+ if pkg_json.get("dependencies"):
524
+ a(
525
+ "**Runtime deps**: "
526
+ + ", ".join(f"`{d}`" for d in pkg_json["dependencies"][:15])
527
+ )
528
+ a("")
529
+ if pkg_json.get("engines"):
530
+ for eng, ver in pkg_json["engines"].items():
531
+ a(f"- **{eng}**: `{ver}`")
532
+ a("")
533
+
534
+
498
535
  def _render_extras(makefile: list, pkg_json: dict) -> list[str]:
499
536
  L: list[str] = []
500
537
  a = L.append
501
538
  if makefile:
502
- a("## Makefile Targets")
503
- a("")
504
- for t in makefile:
505
- desc = f" — {t['desc']}" if t["desc"] else ""
506
- a(f"- `{t['target']}`{desc}")
507
- a("")
539
+ _render_makefile_targets(makefile, a)
508
540
  if pkg_json.get("scripts"):
509
- a("## Node.js Scripts (`package.json`)")
510
- a("")
511
- if pkg_json.get("description"):
512
- a(pkg_json["description"])
513
- a("")
514
- for script, cmd in pkg_json["scripts"].items():
515
- a(f"- `npm run {script}` — `{cmd}`")
516
- a("")
517
- if pkg_json.get("dependencies"):
518
- a(
519
- "**Runtime deps**: "
520
- + ", ".join(f"`{d}`" for d in pkg_json["dependencies"][:15])
521
- )
522
- a("")
523
- if pkg_json.get("engines"):
524
- for eng, ver in pkg_json["engines"].items():
525
- a(f"- **{eng}**: `{ver}`")
526
- a("")
541
+ _render_pkg_json_scripts(pkg_json, a)
527
542
  return L
528
543
 
529
544
 
@@ -637,6 +652,25 @@ def _render_api_stubs(openapi: dict) -> list[str]:
637
652
  return L
638
653
 
639
654
 
655
+ def _render_scenario_contract(sc: dict, a) -> None:
656
+ """Append contract lines for a single scenario to the output via *a*."""
657
+ a(f"**`{sc['name']}`**")
658
+ if sc.get("endpoints"):
659
+ for ep in sc["endpoints"][:3]:
660
+ status = ep.get("status", "")
661
+ op = f" — `{ep['operationId']}`" if ep.get("operationId") else ""
662
+ a(f"- `{ep['method']} {ep['path']}` → `{status}`{op}")
663
+ if sc.get("asserts"):
664
+ for ass in sc["asserts"][:3]:
665
+ a(f"- assert `{ass['field']} {ass['op']} {ass['expected']}`")
666
+ if sc.get("performance"):
667
+ for p in sc["performance"][:2]:
668
+ a(f"- perf `{p['metric']} < {p['threshold']}`")
669
+ if sc.get("detectors"):
670
+ a(f"- detectors: {sc['detectors']}")
671
+ a("")
672
+
673
+
640
674
  def _render_test_contracts(scenarios: list) -> list[str]:
641
675
  """Render test scenarios as contract signatures — endpoint + key assertions."""
642
676
  if not scenarios:
@@ -658,21 +692,7 @@ def _render_test_contracts(scenarios: list) -> list[str]:
658
692
  a(f"### {sc_type.title()} ({len(scs)})")
659
693
  a("")
660
694
  for sc in scs:
661
- a(f"**`{sc['name']}`**")
662
- if sc.get("endpoints"):
663
- for ep in sc["endpoints"][:3]:
664
- status = ep.get("status", "")
665
- op = f" — `{ep['operationId']}`" if ep.get("operationId") else ""
666
- a(f"- `{ep['method']} {ep['path']}` → `{status}`{op}")
667
- if sc.get("asserts"):
668
- for ass in sc["asserts"][:3]:
669
- a(f"- assert `{ass['field']} {ass['op']} {ass['expected']}`")
670
- if sc.get("performance"):
671
- for p in sc["performance"][:2]:
672
- a(f"- perf `{p['metric']} < {p['threshold']}`")
673
- if sc.get("detectors"):
674
- a(f"- detectors: {sc['detectors']}")
675
- a("")
695
+ _render_scenario_contract(sc, a)
676
696
  return L
677
697
 
678
698
 
@@ -695,6 +715,32 @@ def _parse_calls_header(lines: list[str]) -> dict:
695
715
  return result
696
716
 
697
717
 
718
+ def _parse_hub_stat_line(line: str) -> dict | None:
719
+ """Extract CC/in/out/total metrics from a hub stats line, or return None."""
720
+ m = re.search(r"CC=(\d+)\s+in:(\d+)\s+out:(\d+)\s+total:(\d+)", line)
721
+ if not m:
722
+ return None
723
+ return {
724
+ "cc": int(m.group(1)),
725
+ "in": int(m.group(2)),
726
+ "out": int(m.group(3)),
727
+ "total": int(m.group(4)),
728
+ }
729
+
730
+
731
+ def _process_in_hubs_line(line: str, hubs: list, current_hub: dict) -> dict:
732
+ """Process a single indented line while inside the HUBS block."""
733
+ if line.startswith(" "):
734
+ stats = _parse_hub_stat_line(line)
735
+ if stats and current_hub:
736
+ current_hub.update(stats)
737
+ elif line.startswith(" "):
738
+ if current_hub:
739
+ hubs.append(current_hub)
740
+ current_hub = {"name": line.strip()}
741
+ return current_hub
742
+
743
+
698
744
  def _parse_calls_hubs(lines: list[str]) -> list[dict]:
699
745
  """Parse HUBS section into list of hub dicts."""
700
746
  hubs: list[dict] = []
@@ -706,21 +752,8 @@ def _parse_calls_hubs(lines: list[str]) -> list[dict]:
706
752
  continue
707
753
  if in_hubs and line and not line.startswith(" "):
708
754
  in_hubs = False
709
- if in_hubs and line.startswith(" ") and not line.startswith(" "):
710
- if current_hub:
711
- hubs.append(current_hub)
712
- current_hub = {"name": line.strip()}
713
- elif in_hubs and line.startswith(" "):
714
- m = re.search(r"CC=(\d+)\s+in:(\d+)\s+out:(\d+)\s+total:(\d+)", line)
715
- if m and current_hub:
716
- current_hub.update(
717
- {
718
- "cc": int(m.group(1)),
719
- "in": int(m.group(2)),
720
- "out": int(m.group(3)),
721
- "total": int(m.group(4)),
722
- }
723
- )
755
+ if in_hubs:
756
+ current_hub = _process_in_hubs_line(line, hubs, current_hub)
724
757
  if current_hub:
725
758
  hubs.append(current_hub)
726
759
  return hubs
@@ -786,6 +819,31 @@ def _render_call_graph(project_analysis: list) -> list[str]:
786
819
  # ---------------------------------------------------------------------------
787
820
 
788
821
 
822
+ def _collect_tool_sources(
823
+ pyproj: dict, reqs: list, tasks: list, makefile: list, scenarios: list
824
+ ) -> list[str]:
825
+ """Collect source labels for file-based tool inputs."""
826
+ sources: list[str] = []
827
+ if pyproj:
828
+ sources.append("pyproject.toml")
829
+ if reqs:
830
+ sources.extend(r["file"] for r in reqs)
831
+ if tasks:
832
+ sources.append("Taskfile.yml")
833
+ if makefile:
834
+ sources.append("Makefile")
835
+ if scenarios:
836
+ sources.append(f"testql({len(scenarios)})")
837
+ return sources
838
+
839
+
840
+ def _doql_sources(doql: dict) -> list[str]:
841
+ """Return doql source labels if any doql content is present."""
842
+ if doql.get("app") or doql.get("workflows") or doql.get("entities"):
843
+ return doql.get("sources", ["app.doql.less"])
844
+ return []
845
+
846
+
789
847
  def _collect_pkg_sources(
790
848
  pyproj: dict,
791
849
  reqs: list,
@@ -799,21 +857,10 @@ def _collect_pkg_sources(
799
857
  env_vars: list,
800
858
  ) -> list[str]:
801
859
  """Collect source labels for code/pipeline sources."""
802
- sources: list[str] = []
803
- if pyproj:
804
- sources.append("pyproject.toml")
805
- if reqs:
806
- sources.extend(r["file"] for r in reqs)
807
- if tasks:
808
- sources.append("Taskfile.yml")
809
- if makefile:
810
- sources.append("Makefile")
811
- if scenarios:
812
- sources.append(f"testql({len(scenarios)})")
860
+ sources = _collect_tool_sources(pyproj, reqs, tasks, makefile, scenarios)
813
861
  if openapi.get("endpoints"):
814
862
  sources.append(f"openapi({len(openapi['endpoints'])} ep)")
815
- if doql.get("app") or doql.get("workflows") or doql.get("entities"):
816
- sources.extend(doql.get("sources", ["app.doql.less"]))
863
+ sources.extend(_doql_sources(doql))
817
864
  if pyqual.get("stages"):
818
865
  sources.append("pyqual.yaml")
819
866
  if goal.get("name"):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes