sumd 0.3.24__tar.gz → 0.3.26__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.24 → sumd-0.3.26}/CHANGELOG.md +26 -0
- {sumd-0.3.24 → sumd-0.3.26}/PKG-INFO +7 -7
- {sumd-0.3.24 → sumd-0.3.26}/README.md +6 -6
- {sumd-0.3.24 → sumd-0.3.26}/pyproject.toml +1 -1
- {sumd-0.3.24 → sumd-0.3.26}/sumd/__init__.py +4 -2
- {sumd-0.3.24 → sumd-0.3.26}/sumd/cli.py +78 -54
- sumd-0.3.26/sumd/parser.py +233 -0
- sumd-0.3.24/sumd/parser.py → sumd-0.3.26/sumd/validator.py +2 -221
- {sumd-0.3.24 → sumd-0.3.26}/.gitignore +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/LICENSE +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/extractor.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/generator.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/mcp_server.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/pipeline.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/renderer.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/__init__.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/api_stubs.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/architecture.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/base.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/call_graph.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/code_analysis.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/configuration.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/dependencies.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/deployment.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/environment.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/extras.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/interfaces.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/metadata.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/quality.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/refactor_analysis.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/source_snippets.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/test_contracts.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/sections/workflows.py +0 -0
- {sumd-0.3.24 → sumd-0.3.26}/sumd/toon_parser.py +0 -0
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.26] - 2026-04-23
|
|
11
|
+
|
|
12
|
+
### Docs
|
|
13
|
+
- Update README.md
|
|
14
|
+
- Update docs/README.md
|
|
15
|
+
- Update project/README.md
|
|
16
|
+
- Update project/context.md
|
|
17
|
+
|
|
18
|
+
### Other
|
|
19
|
+
- Update project/analysis.toon.yaml
|
|
20
|
+
- Update project/calls.mmd
|
|
21
|
+
- Update project/calls.png
|
|
22
|
+
- Update project/calls.toon.yaml
|
|
23
|
+
- Update project/calls.yaml
|
|
24
|
+
- Update project/compact_flow.mmd
|
|
25
|
+
- Update project/compact_flow.png
|
|
26
|
+
- Update project/duplication.toon.yaml
|
|
27
|
+
- Update project/evolution.toon.yaml
|
|
28
|
+
- Update project/flow.mmd
|
|
29
|
+
- ... and 11 more files
|
|
30
|
+
|
|
31
|
+
## [0.3.25] - 2026-04-22
|
|
32
|
+
|
|
33
|
+
### Docs
|
|
34
|
+
- Update README.md
|
|
35
|
+
|
|
10
36
|
## [0.3.24] - 2026-04-22
|
|
11
37
|
|
|
12
38
|
### Docs
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sumd
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.26
|
|
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
|
|
@@ -41,17 +41,17 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
|
|
42
42
|
## AI Cost Tracking
|
|
43
43
|
|
|
44
|
-
     
|
|
45
|
+
  
|
|
46
46
|
|
|
47
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
48
|
-
- 👤 **Human dev:** ~$
|
|
47
|
+
- 🤖 **LLM usage:** $7.5000 (56 commits)
|
|
48
|
+
- 👤 **Human dev:** ~$1971 (19.7h @ $100/h, 30min dedup)
|
|
49
49
|
|
|
50
|
-
Generated on 2026-04-
|
|
50
|
+
Generated on 2026-04-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
51
51
|
|
|
52
52
|
---
|
|
53
53
|
|
|
54
|
-
  
|
|
55
55
|
|
|
56
56
|
**SUMD** (Structured Unified Markdown Descriptor) is a semantic project descriptor format in Markdown.
|
|
57
57
|
It defines intent, structure, execution entry points, and the mental model of a system for both humans and LLMs.
|
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
|
|
4
4
|
## AI Cost Tracking
|
|
5
5
|
|
|
6
|
-
     
|
|
7
|
+
  
|
|
8
8
|
|
|
9
|
-
- 🤖 **LLM usage:** $7.5000 (
|
|
10
|
-
- 👤 **Human dev:** ~$
|
|
9
|
+
- 🤖 **LLM usage:** $7.5000 (56 commits)
|
|
10
|
+
- 👤 **Human dev:** ~$1971 (19.7h @ $100/h, 30min dedup)
|
|
11
11
|
|
|
12
|
-
Generated on 2026-04-
|
|
12
|
+
Generated on 2026-04-23 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
  
|
|
17
17
|
|
|
18
18
|
**SUMD** (Structured Unified Markdown Descriptor) is a semantic project descriptor format in Markdown.
|
|
19
19
|
It defines intent, structure, execution entry points, and the mental model of a system for both humans and LLMs.
|
|
@@ -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.26"
|
|
8
8
|
|
|
9
9
|
from sumd.parser import (
|
|
10
10
|
SUMDDocument,
|
|
@@ -12,10 +12,12 @@ from sumd.parser import (
|
|
|
12
12
|
parse,
|
|
13
13
|
parse_file,
|
|
14
14
|
validate,
|
|
15
|
+
)
|
|
16
|
+
from sumd.validator import (
|
|
17
|
+
CodeBlockIssue,
|
|
15
18
|
validate_codeblocks,
|
|
16
19
|
validate_markdown,
|
|
17
20
|
validate_sumd_file,
|
|
18
|
-
CodeBlockIssue,
|
|
19
21
|
)
|
|
20
22
|
from sumd.generator import generate_sumd_content
|
|
21
23
|
|
|
@@ -872,6 +872,78 @@ def _echo_scan_result(proj_dir: Path, doc, sources: list, cb_warnings: list) ->
|
|
|
872
872
|
)
|
|
873
873
|
|
|
874
874
|
|
|
875
|
+
def _maybe_generate_doql(proj_dir: Path, fix: bool) -> None:
|
|
876
|
+
"""Generate app.doql.less BEFORE rendering SUMD so it gets included.
|
|
877
|
+
|
|
878
|
+
Pull name/version from the most specific manifest available and
|
|
879
|
+
pick language-appropriate defaults for the boilerplate.
|
|
880
|
+
"""
|
|
881
|
+
project_type = _detect_project_type(proj_dir)
|
|
882
|
+
pyproj = extract_pyproject(proj_dir)
|
|
883
|
+
pkg_json = extract_package_json(proj_dir) if project_type == "node" else {}
|
|
884
|
+
project_name = (
|
|
885
|
+
pyproj.get("name")
|
|
886
|
+
or pkg_json.get("name")
|
|
887
|
+
or proj_dir.name
|
|
888
|
+
)
|
|
889
|
+
version = (
|
|
890
|
+
pyproj.get("version")
|
|
891
|
+
or pkg_json.get("version")
|
|
892
|
+
or "0.1.0"
|
|
893
|
+
)
|
|
894
|
+
doql_path = _generate_doql_less(
|
|
895
|
+
proj_dir, project_name, version,
|
|
896
|
+
force=fix, project_type=project_type,
|
|
897
|
+
)
|
|
898
|
+
if doql_path:
|
|
899
|
+
click.echo(f" 📝 Generated {doql_path.name} ({project_type})")
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _finalize_scan(
|
|
903
|
+
proj_dir: Path,
|
|
904
|
+
doc,
|
|
905
|
+
sources: list,
|
|
906
|
+
cb_warnings: list,
|
|
907
|
+
export_json: bool,
|
|
908
|
+
run_analyze: bool,
|
|
909
|
+
tool_list: list[str],
|
|
910
|
+
doql_sync: bool,
|
|
911
|
+
sumd_path: Path,
|
|
912
|
+
) -> dict:
|
|
913
|
+
"""Run post-scan actions: export JSON, run analysis, DOQL sync."""
|
|
914
|
+
_echo_scan_result(proj_dir, doc, sources, cb_warnings)
|
|
915
|
+
|
|
916
|
+
if export_json:
|
|
917
|
+
_export_sumd_json(proj_dir, doc)
|
|
918
|
+
|
|
919
|
+
if run_analyze:
|
|
920
|
+
click.echo(" 🔬 Running analysis...")
|
|
921
|
+
_run_analysis_tools(proj_dir, tool_list)
|
|
922
|
+
click.echo(f" ✅ Analysis complete → {proj_dir / 'project'}/")
|
|
923
|
+
|
|
924
|
+
if doql_sync and ((proj_dir / "app.doql.less").exists() or (proj_dir / "app.doql.css").exists()):
|
|
925
|
+
click.echo(" ⚙️ Syncing DOQL...")
|
|
926
|
+
r = subprocess.run(
|
|
927
|
+
["doql", "sync"],
|
|
928
|
+
cwd=str(proj_dir),
|
|
929
|
+
capture_output=True,
|
|
930
|
+
text=True,
|
|
931
|
+
)
|
|
932
|
+
if r.returncode == 0:
|
|
933
|
+
click.echo(" ✅ DOQL sync complete")
|
|
934
|
+
else:
|
|
935
|
+
click.echo(f" ⚠️ DOQL sync failed: {r.stderr.strip() or r.stdout.strip()}", err=True)
|
|
936
|
+
|
|
937
|
+
return {
|
|
938
|
+
"status": "OK",
|
|
939
|
+
"project_name": doc.project_name,
|
|
940
|
+
"sections": len(doc.sections),
|
|
941
|
+
"sources": sources,
|
|
942
|
+
"path": str(sumd_path),
|
|
943
|
+
"warnings": [c.message for c in cb_warnings],
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
|
|
875
947
|
def _scan_one_project(
|
|
876
948
|
proj_dir: Path,
|
|
877
949
|
fix: bool,
|
|
@@ -896,29 +968,8 @@ def _scan_one_project(
|
|
|
896
968
|
return {"status": "SKIP", "path": str(sumd_path)}
|
|
897
969
|
|
|
898
970
|
try:
|
|
899
|
-
# Generate app.doql.less BEFORE rendering SUMD so it gets included.
|
|
900
|
-
# Pull name/version from the most specific manifest available and
|
|
901
|
-
# pick language-appropriate defaults for the boilerplate.
|
|
902
971
|
if generate_doql:
|
|
903
|
-
|
|
904
|
-
pyproj = extract_pyproject(proj_dir)
|
|
905
|
-
pkg_json = extract_package_json(proj_dir) if project_type == "node" else {}
|
|
906
|
-
project_name = (
|
|
907
|
-
pyproj.get("name")
|
|
908
|
-
or pkg_json.get("name")
|
|
909
|
-
or proj_dir.name
|
|
910
|
-
)
|
|
911
|
-
version = (
|
|
912
|
-
pyproj.get("version")
|
|
913
|
-
or pkg_json.get("version")
|
|
914
|
-
or "0.1.0"
|
|
915
|
-
)
|
|
916
|
-
doql_path = _generate_doql_less(
|
|
917
|
-
proj_dir, project_name, version,
|
|
918
|
-
force=fix, project_type=project_type,
|
|
919
|
-
)
|
|
920
|
-
if doql_path:
|
|
921
|
-
click.echo(f" 📝 Generated {doql_path.name} ({project_type})")
|
|
972
|
+
_maybe_generate_doql(proj_dir, fix)
|
|
922
973
|
|
|
923
974
|
doc, md_issues, cb_errors, cb_warnings, sources = _render_write_validate(
|
|
924
975
|
proj_dir, sumd_path, raw, profile
|
|
@@ -933,37 +984,10 @@ def _scan_one_project(
|
|
|
933
984
|
click.echo(f" \u2193 {e}")
|
|
934
985
|
return {"status": "INVALID", "errors": all_errors, "path": str(sumd_path)}
|
|
935
986
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
if run_analyze:
|
|
942
|
-
click.echo(" \U0001f52c Running analysis...")
|
|
943
|
-
_run_analysis_tools(proj_dir, tool_list)
|
|
944
|
-
click.echo(f" \u2705 Analysis complete \u2192 {proj_dir / 'project'}/")
|
|
945
|
-
|
|
946
|
-
if doql_sync and ((proj_dir / "app.doql.less").exists() or (proj_dir / "app.doql.css").exists()):
|
|
947
|
-
click.echo(" \u2699\ufe0f Syncing DOQL...")
|
|
948
|
-
r = subprocess.run(
|
|
949
|
-
["doql", "sync"],
|
|
950
|
-
cwd=str(proj_dir),
|
|
951
|
-
capture_output=True,
|
|
952
|
-
text=True,
|
|
953
|
-
)
|
|
954
|
-
if r.returncode == 0:
|
|
955
|
-
click.echo(" \u2705 DOQL sync complete")
|
|
956
|
-
else:
|
|
957
|
-
click.echo(f" \u26a0\ufe0f DOQL sync failed: {r.stderr.strip() or r.stdout.strip()}", err=True)
|
|
958
|
-
|
|
959
|
-
return {
|
|
960
|
-
"status": "OK",
|
|
961
|
-
"project_name": doc.project_name,
|
|
962
|
-
"sections": len(doc.sections),
|
|
963
|
-
"sources": sources,
|
|
964
|
-
"path": str(sumd_path),
|
|
965
|
-
"warnings": [c.message for c in cb_warnings],
|
|
966
|
-
}
|
|
987
|
+
return _finalize_scan(
|
|
988
|
+
proj_dir, doc, sources, cb_warnings,
|
|
989
|
+
export_json, run_analyze, tool_list, doql_sync, sumd_path,
|
|
990
|
+
)
|
|
967
991
|
|
|
968
992
|
except Exception as exc:
|
|
969
993
|
dash = "\u2013"
|
|
@@ -975,7 +999,7 @@ def _scan_one_project(
|
|
|
975
999
|
@click.argument("workspace", type=click.Path(exists=True, path_type=Path), default=".")
|
|
976
1000
|
@click.option(
|
|
977
1001
|
"--export-json/--no-export-json",
|
|
978
|
-
default=
|
|
1002
|
+
default=False,
|
|
979
1003
|
help="Also export sumd.json per project",
|
|
980
1004
|
)
|
|
981
1005
|
@click.option(
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""SUMD Parser - Parse and validate SUMD markdown documents."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SectionType(Enum):
|
|
11
|
+
"""SUMD section types."""
|
|
12
|
+
|
|
13
|
+
PROJECT_HEADER = "project_header"
|
|
14
|
+
METADATA = "metadata"
|
|
15
|
+
INTENT = "intent"
|
|
16
|
+
ARCHITECTURE = "architecture"
|
|
17
|
+
INTERFACES = "interfaces"
|
|
18
|
+
WORKFLOWS = "workflows"
|
|
19
|
+
CONFIGURATION = "configuration"
|
|
20
|
+
DEPENDENCIES = "dependencies"
|
|
21
|
+
DEPLOYMENT = "deployment"
|
|
22
|
+
UNKNOWN = "unknown"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Section:
|
|
27
|
+
"""Represents a SUMD section."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
type: SectionType
|
|
31
|
+
content: str
|
|
32
|
+
level: int
|
|
33
|
+
subsections: List["Section"] = field(default_factory=list)
|
|
34
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SUMDDocument:
|
|
39
|
+
"""Represents a parsed SUMD document."""
|
|
40
|
+
|
|
41
|
+
project_name: str
|
|
42
|
+
description: str
|
|
43
|
+
sections: List[Section] = field(default_factory=list)
|
|
44
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
raw_content: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SUMDParser:
|
|
49
|
+
"""Parser for SUMD markdown documents."""
|
|
50
|
+
|
|
51
|
+
SECTION_MAPPING = {
|
|
52
|
+
"metadata": SectionType.METADATA,
|
|
53
|
+
"intent": SectionType.INTENT,
|
|
54
|
+
"architecture": SectionType.ARCHITECTURE,
|
|
55
|
+
"interfaces": SectionType.INTERFACES,
|
|
56
|
+
"workflows": SectionType.WORKFLOWS,
|
|
57
|
+
"configuration": SectionType.CONFIGURATION,
|
|
58
|
+
"dependencies": SectionType.DEPENDENCIES,
|
|
59
|
+
"deployment": SectionType.DEPLOYMENT,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def __init__(self):
|
|
63
|
+
self.current_document: Optional[SUMDDocument] = None
|
|
64
|
+
|
|
65
|
+
def parse(self, content: str) -> SUMDDocument:
|
|
66
|
+
"""Parse a SUMD markdown document.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
content: The markdown content to parse
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
SUMDDocument: Parsed document structure
|
|
73
|
+
"""
|
|
74
|
+
self.current_document = SUMDDocument(
|
|
75
|
+
project_name="", description="", raw_content=content
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
lines = content.split("\n")
|
|
79
|
+
self._parse_header(lines)
|
|
80
|
+
self._parse_sections(lines)
|
|
81
|
+
|
|
82
|
+
return self.current_document
|
|
83
|
+
|
|
84
|
+
def parse_file(self, path: Path) -> SUMDDocument:
|
|
85
|
+
"""Parse a SUMD file.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
path: Path to the SUMD markdown file
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
SUMDDocument: Parsed document structure
|
|
92
|
+
"""
|
|
93
|
+
content = path.read_text(encoding="utf-8")
|
|
94
|
+
return self.parse(content)
|
|
95
|
+
|
|
96
|
+
def _parse_header(self, lines: List[str]) -> None:
|
|
97
|
+
"""Parse the project header (H1).
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
lines: List of document lines
|
|
101
|
+
"""
|
|
102
|
+
if not lines:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Find H1 header
|
|
106
|
+
for i, line in enumerate(lines):
|
|
107
|
+
if line.startswith("# ") and not line.startswith("##"):
|
|
108
|
+
header_content = line[2:].strip()
|
|
109
|
+
# Split into name and description
|
|
110
|
+
parts = header_content.split(" - ", 1)
|
|
111
|
+
self.current_document.project_name = parts[0].strip()
|
|
112
|
+
if len(parts) > 1:
|
|
113
|
+
self.current_document.description = parts[1].strip()
|
|
114
|
+
else:
|
|
115
|
+
# Look for description on next lines
|
|
116
|
+
for j in range(i + 1, min(i + 3, len(lines))):
|
|
117
|
+
if lines[j].strip() and not lines[j].startswith("#"):
|
|
118
|
+
self.current_document.description = lines[j].strip()
|
|
119
|
+
break
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
def _parse_sections(self, lines: List[str]) -> None:
|
|
123
|
+
"""Parse all sections in the document.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
lines: List of document lines
|
|
127
|
+
"""
|
|
128
|
+
sections = []
|
|
129
|
+
current_section = None
|
|
130
|
+
current_content = []
|
|
131
|
+
|
|
132
|
+
for line in lines:
|
|
133
|
+
if line.startswith("##"):
|
|
134
|
+
# Save previous section
|
|
135
|
+
if current_section:
|
|
136
|
+
current_section.content = "\n".join(current_content).strip()
|
|
137
|
+
sections.append(current_section)
|
|
138
|
+
current_content = []
|
|
139
|
+
|
|
140
|
+
# Start new section
|
|
141
|
+
section_name = line[2:].strip().lower()
|
|
142
|
+
section_type = self.SECTION_MAPPING.get(
|
|
143
|
+
section_name, SectionType.UNKNOWN
|
|
144
|
+
)
|
|
145
|
+
current_section = Section(
|
|
146
|
+
name=section_name, type=section_type, content="", level=2
|
|
147
|
+
)
|
|
148
|
+
elif current_section:
|
|
149
|
+
current_content.append(line)
|
|
150
|
+
|
|
151
|
+
# Save last section
|
|
152
|
+
if current_section:
|
|
153
|
+
current_section.content = "\n".join(current_content).strip()
|
|
154
|
+
sections.append(current_section)
|
|
155
|
+
|
|
156
|
+
self.current_document.sections = sections
|
|
157
|
+
|
|
158
|
+
def validate(self, document: SUMDDocument) -> List[str]:
|
|
159
|
+
"""Validate a SUMD document against the specification.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
document: The document to validate
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
List of validation errors (empty if valid)
|
|
166
|
+
"""
|
|
167
|
+
errors = []
|
|
168
|
+
|
|
169
|
+
# Check required sections
|
|
170
|
+
section_types = {section.type for section in document.sections}
|
|
171
|
+
|
|
172
|
+
if SectionType.INTENT not in section_types:
|
|
173
|
+
errors.append("Missing required section: Intent")
|
|
174
|
+
|
|
175
|
+
if SectionType.ARCHITECTURE not in section_types:
|
|
176
|
+
errors.append("Missing required section: Architecture")
|
|
177
|
+
|
|
178
|
+
if SectionType.INTERFACES not in section_types:
|
|
179
|
+
errors.append("Missing required section: Interfaces")
|
|
180
|
+
|
|
181
|
+
# Check project name
|
|
182
|
+
if not document.project_name:
|
|
183
|
+
errors.append("Missing project name in header")
|
|
184
|
+
|
|
185
|
+
return errors
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def parse(content: str) -> SUMDDocument:
|
|
189
|
+
"""Parse a SUMD markdown document.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
content: The markdown content to parse
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
SUMDDocument: Parsed document structure
|
|
196
|
+
"""
|
|
197
|
+
parser = SUMDParser()
|
|
198
|
+
return parser.parse(content)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_file(path: Path) -> SUMDDocument:
|
|
202
|
+
"""Parse a SUMD file.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
path: Path to the SUMD markdown file
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
SUMDDocument: Parsed document structure
|
|
209
|
+
"""
|
|
210
|
+
parser = SUMDParser()
|
|
211
|
+
return parser.parse_file(path)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def validate(document: SUMDDocument) -> List[str]:
|
|
215
|
+
"""Validate a SUMD document.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
document: The document to validate
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
List of validation errors (empty if valid)
|
|
222
|
+
"""
|
|
223
|
+
parser = SUMDParser()
|
|
224
|
+
return parser.validate(document)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# Backward-compatible re-exports from validator module
|
|
228
|
+
from sumd.validator import (
|
|
229
|
+
CodeBlockIssue,
|
|
230
|
+
validate_codeblocks,
|
|
231
|
+
validate_markdown,
|
|
232
|
+
validate_sumd_file,
|
|
233
|
+
)
|
|
@@ -1,227 +1,8 @@
|
|
|
1
|
-
"""SUMD
|
|
1
|
+
"""SUMD Validator — validate markdown structure and fenced codeblocks."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
-
from dataclasses import dataclass
|
|
5
|
-
from enum import Enum
|
|
4
|
+
from dataclasses import dataclass
|
|
6
5
|
from pathlib import Path
|
|
7
|
-
from typing import Any, Dict, List, Optional
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class SectionType(Enum):
|
|
11
|
-
"""SUMD section types."""
|
|
12
|
-
|
|
13
|
-
PROJECT_HEADER = "project_header"
|
|
14
|
-
METADATA = "metadata"
|
|
15
|
-
INTENT = "intent"
|
|
16
|
-
ARCHITECTURE = "architecture"
|
|
17
|
-
INTERFACES = "interfaces"
|
|
18
|
-
WORKFLOWS = "workflows"
|
|
19
|
-
CONFIGURATION = "configuration"
|
|
20
|
-
DEPENDENCIES = "dependencies"
|
|
21
|
-
DEPLOYMENT = "deployment"
|
|
22
|
-
UNKNOWN = "unknown"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@dataclass
|
|
26
|
-
class Section:
|
|
27
|
-
"""Represents a SUMD section."""
|
|
28
|
-
|
|
29
|
-
name: str
|
|
30
|
-
type: SectionType
|
|
31
|
-
content: str
|
|
32
|
-
level: int
|
|
33
|
-
subsections: List["Section"] = field(default_factory=list)
|
|
34
|
-
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
@dataclass
|
|
38
|
-
class SUMDDocument:
|
|
39
|
-
"""Represents a parsed SUMD document."""
|
|
40
|
-
|
|
41
|
-
project_name: str
|
|
42
|
-
description: str
|
|
43
|
-
sections: List[Section] = field(default_factory=list)
|
|
44
|
-
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
45
|
-
raw_content: str = ""
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class SUMDParser:
|
|
49
|
-
"""Parser for SUMD markdown documents."""
|
|
50
|
-
|
|
51
|
-
SECTION_MAPPING = {
|
|
52
|
-
"metadata": SectionType.METADATA,
|
|
53
|
-
"intent": SectionType.INTENT,
|
|
54
|
-
"architecture": SectionType.ARCHITECTURE,
|
|
55
|
-
"interfaces": SectionType.INTERFACES,
|
|
56
|
-
"workflows": SectionType.WORKFLOWS,
|
|
57
|
-
"configuration": SectionType.CONFIGURATION,
|
|
58
|
-
"dependencies": SectionType.DEPENDENCIES,
|
|
59
|
-
"deployment": SectionType.DEPLOYMENT,
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
def __init__(self):
|
|
63
|
-
self.current_document: Optional[SUMDDocument] = None
|
|
64
|
-
|
|
65
|
-
def parse(self, content: str) -> SUMDDocument:
|
|
66
|
-
"""Parse a SUMD markdown document.
|
|
67
|
-
|
|
68
|
-
Args:
|
|
69
|
-
content: The markdown content to parse
|
|
70
|
-
|
|
71
|
-
Returns:
|
|
72
|
-
SUMDDocument: Parsed document structure
|
|
73
|
-
"""
|
|
74
|
-
self.current_document = SUMDDocument(
|
|
75
|
-
project_name="", description="", raw_content=content
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
lines = content.split("\n")
|
|
79
|
-
self._parse_header(lines)
|
|
80
|
-
self._parse_sections(lines)
|
|
81
|
-
|
|
82
|
-
return self.current_document
|
|
83
|
-
|
|
84
|
-
def parse_file(self, path: Path) -> SUMDDocument:
|
|
85
|
-
"""Parse a SUMD file.
|
|
86
|
-
|
|
87
|
-
Args:
|
|
88
|
-
path: Path to the SUMD markdown file
|
|
89
|
-
|
|
90
|
-
Returns:
|
|
91
|
-
SUMDDocument: Parsed document structure
|
|
92
|
-
"""
|
|
93
|
-
content = path.read_text(encoding="utf-8")
|
|
94
|
-
return self.parse(content)
|
|
95
|
-
|
|
96
|
-
def _parse_header(self, lines: List[str]) -> None:
|
|
97
|
-
"""Parse the project header (H1).
|
|
98
|
-
|
|
99
|
-
Args:
|
|
100
|
-
lines: List of document lines
|
|
101
|
-
"""
|
|
102
|
-
if not lines:
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
# Find H1 header
|
|
106
|
-
for i, line in enumerate(lines):
|
|
107
|
-
if line.startswith("# ") and not line.startswith("##"):
|
|
108
|
-
header_content = line[2:].strip()
|
|
109
|
-
# Split into name and description
|
|
110
|
-
parts = header_content.split(" - ", 1)
|
|
111
|
-
self.current_document.project_name = parts[0].strip()
|
|
112
|
-
if len(parts) > 1:
|
|
113
|
-
self.current_document.description = parts[1].strip()
|
|
114
|
-
else:
|
|
115
|
-
# Look for description on next lines
|
|
116
|
-
for j in range(i + 1, min(i + 3, len(lines))):
|
|
117
|
-
if lines[j].strip() and not lines[j].startswith("#"):
|
|
118
|
-
self.current_document.description = lines[j].strip()
|
|
119
|
-
break
|
|
120
|
-
break
|
|
121
|
-
|
|
122
|
-
def _parse_sections(self, lines: List[str]) -> None:
|
|
123
|
-
"""Parse all sections in the document.
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
lines: List of document lines
|
|
127
|
-
"""
|
|
128
|
-
sections = []
|
|
129
|
-
current_section = None
|
|
130
|
-
current_content = []
|
|
131
|
-
|
|
132
|
-
for line in lines:
|
|
133
|
-
if line.startswith("##"):
|
|
134
|
-
# Save previous section
|
|
135
|
-
if current_section:
|
|
136
|
-
current_section.content = "\n".join(current_content).strip()
|
|
137
|
-
sections.append(current_section)
|
|
138
|
-
current_content = []
|
|
139
|
-
|
|
140
|
-
# Start new section
|
|
141
|
-
section_name = line[2:].strip().lower()
|
|
142
|
-
section_type = self.SECTION_MAPPING.get(
|
|
143
|
-
section_name, SectionType.UNKNOWN
|
|
144
|
-
)
|
|
145
|
-
current_section = Section(
|
|
146
|
-
name=section_name, type=section_type, content="", level=2
|
|
147
|
-
)
|
|
148
|
-
elif current_section:
|
|
149
|
-
current_content.append(line)
|
|
150
|
-
|
|
151
|
-
# Save last section
|
|
152
|
-
if current_section:
|
|
153
|
-
current_section.content = "\n".join(current_content).strip()
|
|
154
|
-
sections.append(current_section)
|
|
155
|
-
|
|
156
|
-
self.current_document.sections = sections
|
|
157
|
-
|
|
158
|
-
def validate(self, document: SUMDDocument) -> List[str]:
|
|
159
|
-
"""Validate a SUMD document against the specification.
|
|
160
|
-
|
|
161
|
-
Args:
|
|
162
|
-
document: The document to validate
|
|
163
|
-
|
|
164
|
-
Returns:
|
|
165
|
-
List of validation errors (empty if valid)
|
|
166
|
-
"""
|
|
167
|
-
errors = []
|
|
168
|
-
|
|
169
|
-
# Check required sections
|
|
170
|
-
section_types = {section.type for section in document.sections}
|
|
171
|
-
|
|
172
|
-
if SectionType.INTENT not in section_types:
|
|
173
|
-
errors.append("Missing required section: Intent")
|
|
174
|
-
|
|
175
|
-
if SectionType.ARCHITECTURE not in section_types:
|
|
176
|
-
errors.append("Missing required section: Architecture")
|
|
177
|
-
|
|
178
|
-
if SectionType.INTERFACES not in section_types:
|
|
179
|
-
errors.append("Missing required section: Interfaces")
|
|
180
|
-
|
|
181
|
-
# Check project name
|
|
182
|
-
if not document.project_name:
|
|
183
|
-
errors.append("Missing project name in header")
|
|
184
|
-
|
|
185
|
-
return errors
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def parse(content: str) -> SUMDDocument:
|
|
189
|
-
"""Parse a SUMD markdown document.
|
|
190
|
-
|
|
191
|
-
Args:
|
|
192
|
-
content: The markdown content to parse
|
|
193
|
-
|
|
194
|
-
Returns:
|
|
195
|
-
SUMDDocument: Parsed document structure
|
|
196
|
-
"""
|
|
197
|
-
parser = SUMDParser()
|
|
198
|
-
return parser.parse(content)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def parse_file(path: Path) -> SUMDDocument:
|
|
202
|
-
"""Parse a SUMD file.
|
|
203
|
-
|
|
204
|
-
Args:
|
|
205
|
-
path: Path to the SUMD markdown file
|
|
206
|
-
|
|
207
|
-
Returns:
|
|
208
|
-
SUMDDocument: Parsed document structure
|
|
209
|
-
"""
|
|
210
|
-
parser = SUMDParser()
|
|
211
|
-
return parser.parse_file(path)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def validate(document: SUMDDocument) -> List[str]:
|
|
215
|
-
"""Validate a SUMD document.
|
|
216
|
-
|
|
217
|
-
Args:
|
|
218
|
-
document: The document to validate
|
|
219
|
-
|
|
220
|
-
Returns:
|
|
221
|
-
List of validation errors (empty if valid)
|
|
222
|
-
"""
|
|
223
|
-
parser = SUMDParser()
|
|
224
|
-
return parser.validate(document)
|
|
225
6
|
|
|
226
7
|
|
|
227
8
|
# ---------------------------------------------------------------------------
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|