sumd 0.3.4__tar.gz → 0.3.5__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.
- {sumd-0.3.4 → sumd-0.3.5}/CHANGELOG.md +32 -0
- {sumd-0.3.4 → sumd-0.3.5}/PKG-INFO +23 -18
- {sumd-0.3.4 → sumd-0.3.5}/README.md +21 -17
- {sumd-0.3.4 → sumd-0.3.5}/pyproject.toml +2 -1
- {sumd-0.3.4 → sumd-0.3.5}/sumd/__init__.py +1 -1
- {sumd-0.3.4 → sumd-0.3.5}/sumd/cli.py +50 -64
- {sumd-0.3.4 → sumd-0.3.5}/sumd/extractor.py +108 -86
- {sumd-0.3.4 → sumd-0.3.5}/sumd/parser.py +30 -23
- {sumd-0.3.4 → sumd-0.3.5}/sumd/pipeline.py +37 -36
- {sumd-0.3.4 → sumd-0.3.5}/sumd/renderer.py +125 -78
- {sumd-0.3.4 → sumd-0.3.5}/.gitignore +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/LICENSE +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/generator.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/mcp_server.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/__init__.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/api_stubs.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/architecture.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/base.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/call_graph.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/code_analysis.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/configuration.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/dependencies.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/deployment.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/environment.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/extras.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/interfaces.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/metadata.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/quality.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/refactor_analysis.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/source_snippets.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/test_contracts.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/sections/workflows.py +0 -0
- {sumd-0.3.4 → sumd-0.3.5}/sumd/toon_parser.py +0 -0
|
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.5] - 2026-04-19
|
|
11
|
+
|
|
12
|
+
### Docs
|
|
13
|
+
- Update README.md
|
|
14
|
+
- Update SUMD.md
|
|
15
|
+
- Update SUMR.md
|
|
16
|
+
- Update docs/README.md
|
|
17
|
+
- Update docs/USAGE.md
|
|
18
|
+
- Update examples/README.md
|
|
19
|
+
- Update examples/basic/README.md
|
|
20
|
+
- Update examples/basic/sample-project/README.md
|
|
21
|
+
- Update examples/basic/sample-project/SUMD.md
|
|
22
|
+
- Update examples/integrations/README.md
|
|
23
|
+
- ... and 5 more files
|
|
24
|
+
|
|
25
|
+
### Test
|
|
26
|
+
- Update tests/test_cli.py
|
|
27
|
+
- Update tests/test_mcp_server.py
|
|
28
|
+
|
|
29
|
+
### Other
|
|
30
|
+
- Update Taskfile.yml
|
|
31
|
+
- Update examples/basic/demo.sh
|
|
32
|
+
- Update examples/basic/sample-project/Taskfile.yml
|
|
33
|
+
- Update examples/basic/sample-project/goal.yaml
|
|
34
|
+
- Update examples/basic/sample-project/openapi.yaml
|
|
35
|
+
- Update examples/basic/sample-project/project/map.toon.yaml
|
|
36
|
+
- Update examples/basic/sample-project/pyproject.toml
|
|
37
|
+
- Update examples/basic/sample-project/sumd.json
|
|
38
|
+
- Update examples/integrations/Dockerfile
|
|
39
|
+
- Update examples/integrations/docker-compose.yml
|
|
40
|
+
- ... and 23 more files
|
|
41
|
+
|
|
10
42
|
## [0.3.4] - 2026-04-19
|
|
11
43
|
|
|
12
44
|
### Docs
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sumd
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
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
|
-
   
|
|
45
|
+
  
|
|
43
46
|
|
|
44
|
-
|
|
47
|
+
- 🤖 **LLM usage:** $5.2500 (35 commits)
|
|
48
|
+
- 👤 **Human dev:** ~$1256 (12.6h @ $100/h, 30min dedup)
|
|
45
49
|
|
|
46
|
-
Generated on 2026-04-19 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/
|
|
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
|
+
  
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
65
|
-
- **AI
|
|
66
|
-
- **Lightweight manifest
|
|
67
|
-
- **
|
|
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
|
-
-
|
|
72
|
-
-
|
|
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
|
-
   
|
|
7
|
+
  
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
- 🤖 **LLM usage:** $5.2500 (35 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$1256 (12.6h @ $100/h, 30min dedup)
|
|
8
11
|
|
|
9
|
-
Generated on 2026-04-19 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/
|
|
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
|
+
  
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
28
|
-
- **AI
|
|
29
|
-
- **Lightweight manifest
|
|
30
|
-
- **
|
|
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
|
-
-
|
|
35
|
-
-
|
|
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.
|
|
7
|
+
version = "0.3.5"
|
|
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.
|
|
7
|
+
__version__ = "0.3.5"
|
|
8
8
|
|
|
9
9
|
from sumd.parser import (
|
|
10
10
|
SUMDDocument,
|
|
@@ -237,15 +237,57 @@ def _detect_projects(workspace: Path, max_depth: int | None = None) -> list[Path
|
|
|
237
237
|
for d in entries:
|
|
238
238
|
if not d.is_dir() or d.name.startswith(".") or d.name in _SKIP_DIRS:
|
|
239
239
|
continue
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
240
|
+
try:
|
|
241
|
+
if (d / "pyproject.toml").exists():
|
|
242
|
+
projects.append(d)
|
|
243
|
+
else:
|
|
244
|
+
_walk(d, depth + 1)
|
|
245
|
+
except PermissionError:
|
|
246
|
+
continue
|
|
244
247
|
|
|
245
248
|
_walk(workspace, 0)
|
|
246
249
|
return projects
|
|
247
250
|
|
|
248
251
|
|
|
252
|
+
def _ensure_venv(tools_dir: Path, venv_dir: Path, tool_list: list[str]) -> None:
|
|
253
|
+
"""Create virtual env and install tools if not already present."""
|
|
254
|
+
if venv_dir.exists():
|
|
255
|
+
return
|
|
256
|
+
tools_dir.mkdir(exist_ok=True)
|
|
257
|
+
subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], capture_output=True)
|
|
258
|
+
pip_path = venv_dir / "bin" / "pip"
|
|
259
|
+
if not pip_path.exists():
|
|
260
|
+
pip_path = venv_dir / "Scripts" / "pip.exe"
|
|
261
|
+
for pkg in tool_list:
|
|
262
|
+
subprocess.run([str(pip_path), "install", "-q", pkg], capture_output=True)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _tool_bin(bin_dir: Path, name: str) -> Path:
|
|
266
|
+
"""Return path to *name* executable, preferring non-.exe variant."""
|
|
267
|
+
plain = bin_dir / name
|
|
268
|
+
return plain if plain.exists() else bin_dir / f"{name}.exe"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# Tool-specific CLI argument templates
|
|
272
|
+
_TOOL_ARGS: dict[str, list[str]] = {
|
|
273
|
+
"code2llm": ["{proj_dir}", "-f", "all", "-o", "{out}", "--no-chunk"],
|
|
274
|
+
"redup": ["scan", "{proj_dir}", "--format", "toon", "--output", "{out}"],
|
|
275
|
+
"vallm": ["batch", "{proj_dir}", "--recursive", "--format", "toon", "--output", "{out}"],
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _run_one_tool(tool: str, bin_dir: Path, proj_dir: Path, project_output: Path) -> None:
|
|
280
|
+
"""Run a single analysis tool if its arg template is known."""
|
|
281
|
+
template = _TOOL_ARGS.get(tool)
|
|
282
|
+
if template is None:
|
|
283
|
+
return
|
|
284
|
+
args = [
|
|
285
|
+
a.replace("{proj_dir}", str(proj_dir)).replace("{out}", str(project_output))
|
|
286
|
+
for a in template
|
|
287
|
+
]
|
|
288
|
+
subprocess.run([str(_tool_bin(bin_dir, tool))] + args, capture_output=True, cwd=str(proj_dir))
|
|
289
|
+
|
|
290
|
+
|
|
249
291
|
def _run_analysis_tools(
|
|
250
292
|
proj_dir: Path,
|
|
251
293
|
tool_list: list[str],
|
|
@@ -261,16 +303,7 @@ def _run_analysis_tools(
|
|
|
261
303
|
venv_dir = tools_dir / "venv"
|
|
262
304
|
project_output = proj_dir / "project"
|
|
263
305
|
|
|
264
|
-
|
|
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)
|
|
306
|
+
_ensure_venv(tools_dir, venv_dir, tool_list)
|
|
274
307
|
|
|
275
308
|
bin_dir = venv_dir / "bin"
|
|
276
309
|
if not bin_dir.exists():
|
|
@@ -278,56 +311,9 @@ def _run_analysis_tools(
|
|
|
278
311
|
|
|
279
312
|
project_output.mkdir(exist_ok=True)
|
|
280
313
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
)
|
|
314
|
+
for tool in tool_list:
|
|
315
|
+
if tool not in skip_tools:
|
|
316
|
+
_run_one_tool(tool, bin_dir, proj_dir, project_output)
|
|
331
317
|
|
|
332
318
|
|
|
333
319
|
def _export_sumd_json(proj_dir: Path, doc) -> None:
|
|
@@ -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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
|
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":
|
|
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
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
if not bin_dir
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
710
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|