dddlint 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dddlint/__init__.py +1 -0
- dddlint/check.py +135 -0
- dddlint/cli.py +146 -0
- dddlint/config.py +31 -0
- dddlint/config_check.py +89 -0
- dddlint/extract.py +82 -0
- dddlint/mcp_server.py +76 -0
- dddlint/server.py +159 -0
- dddlint/view.py +364 -0
- dddlint-0.1.2.dist-info/METADATA +184 -0
- dddlint-0.1.2.dist-info/RECORD +13 -0
- dddlint-0.1.2.dist-info/WHEEL +4 -0
- dddlint-0.1.2.dist-info/entry_points.txt +3 -0
dddlint/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
dddlint/check.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from fnmatch import fnmatch
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .config import Config, Context, SynonymGroup
|
|
8
|
+
from .extract import Definition
|
|
9
|
+
|
|
10
|
+
_BOUNDARY = r"[-_\s]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Finding:
|
|
15
|
+
path: Path
|
|
16
|
+
line: int
|
|
17
|
+
name: str
|
|
18
|
+
rule: str
|
|
19
|
+
message: str
|
|
20
|
+
col: int = 0
|
|
21
|
+
fix: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _apply_fix(name: str, old_token: str, new_token: str) -> str:
|
|
25
|
+
assert name and old_token, "name and old_token must be non-empty"
|
|
26
|
+
assert new_token is not None, "new_token must not be None"
|
|
27
|
+
|
|
28
|
+
def _recase(m: re.Match) -> str:
|
|
29
|
+
assert m is not None, "match must not be None"
|
|
30
|
+
s = m.group()
|
|
31
|
+
assert s, "matched text must be non-empty"
|
|
32
|
+
if s.isupper():
|
|
33
|
+
return new_token.upper()
|
|
34
|
+
if s[0].isupper():
|
|
35
|
+
return new_token.capitalize()
|
|
36
|
+
return new_token
|
|
37
|
+
|
|
38
|
+
return re.sub(re.escape(old_token), _recase, name, flags=re.IGNORECASE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def tokenise(name: str) -> tuple[str, ...]:
|
|
42
|
+
assert name is not None, "name must not be None"
|
|
43
|
+
assert isinstance(name, str), "name must be a string"
|
|
44
|
+
return tuple(p.lower() for p in re.split(_BOUNDARY, name) if p)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _scoped(context: Context, path: Path) -> bool:
|
|
48
|
+
assert context is not None, "context must not be None"
|
|
49
|
+
assert isinstance(path, Path), "path must be a Path object"
|
|
50
|
+
return any(fnmatch(str(path), pattern) for pattern in context.include)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _alias_map(groups: list[SynonymGroup]) -> dict[str, str]:
|
|
54
|
+
assert groups is not None, "groups must not be None"
|
|
55
|
+
assert isinstance(groups, list), "groups must be a list"
|
|
56
|
+
out: dict[str, str] = {}
|
|
57
|
+
for group in groups:
|
|
58
|
+
for alias in group.aliases:
|
|
59
|
+
out[alias.lower()] = group.canonical
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_one(
|
|
64
|
+
definition: Definition,
|
|
65
|
+
forbidden: set[str],
|
|
66
|
+
aliases: dict[str, str],
|
|
67
|
+
enforce_canonical: bool,
|
|
68
|
+
) -> list[Finding]:
|
|
69
|
+
assert definition is not None, "definition must not be None"
|
|
70
|
+
assert isinstance(forbidden, set), "forbidden must be a set"
|
|
71
|
+
out: list[Finding] = []
|
|
72
|
+
for token in tokenise(definition.name):
|
|
73
|
+
if token in forbidden:
|
|
74
|
+
out.append(
|
|
75
|
+
Finding(
|
|
76
|
+
definition.path,
|
|
77
|
+
definition.line,
|
|
78
|
+
definition.name,
|
|
79
|
+
"forbidden",
|
|
80
|
+
f"uses banned term '{token}'",
|
|
81
|
+
col=definition.col,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
if enforce_canonical and token in aliases:
|
|
85
|
+
canonical = aliases[token]
|
|
86
|
+
out.append(
|
|
87
|
+
Finding(
|
|
88
|
+
definition.path,
|
|
89
|
+
definition.line,
|
|
90
|
+
definition.name,
|
|
91
|
+
"alias",
|
|
92
|
+
f"'{token}' is not the canonical term, use '{canonical}'",
|
|
93
|
+
col=definition.col,
|
|
94
|
+
fix=_apply_fix(definition.name, token, canonical),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def check(definitions: list[Definition], config: Config) -> list[Finding]:
|
|
101
|
+
assert definitions is not None, "definitions must not be None"
|
|
102
|
+
assert config is not None, "config must not be None"
|
|
103
|
+
out: list[Finding] = []
|
|
104
|
+
base_forbidden = {t.lower() for t in config.forbidden}
|
|
105
|
+
base_aliases = _alias_map(config.synonyms)
|
|
106
|
+
|
|
107
|
+
for definition in definitions:
|
|
108
|
+
forbidden = set(base_forbidden)
|
|
109
|
+
aliases = dict(base_aliases)
|
|
110
|
+
for scope in config.domains + config.contexts:
|
|
111
|
+
if _scoped(scope, definition.path):
|
|
112
|
+
forbidden |= {t.lower() for t in scope.forbidden}
|
|
113
|
+
aliases |= _alias_map(scope.synonyms)
|
|
114
|
+
out.extend(_check_one(definition, forbidden, aliases, config.enforce_canonical))
|
|
115
|
+
|
|
116
|
+
by_tokens: dict[tuple[str, ...], set[str]] = defaultdict(set)
|
|
117
|
+
first: dict[tuple[str, ...], Definition] = {}
|
|
118
|
+
for definition in definitions:
|
|
119
|
+
key = tuple(sorted(tokenise(definition.name)))
|
|
120
|
+
by_tokens[key].add(definition.name)
|
|
121
|
+
first.setdefault(key, definition)
|
|
122
|
+
for key, names in by_tokens.items():
|
|
123
|
+
if len(names) > 1:
|
|
124
|
+
definition = first[key]
|
|
125
|
+
spellings = ", ".join(sorted(names))
|
|
126
|
+
out.append(
|
|
127
|
+
Finding(
|
|
128
|
+
definition.path,
|
|
129
|
+
definition.line,
|
|
130
|
+
definition.name,
|
|
131
|
+
"drift",
|
|
132
|
+
f"one concept spelled several ways: {spellings}",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
return out
|
dddlint/cli.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from .check import Finding, check
|
|
9
|
+
from .config import load_config
|
|
10
|
+
from .config_check import check_config
|
|
11
|
+
from .extract import Definition, definitions, language_for
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
add_completion=False,
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
17
|
+
)
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _version(value: bool = False) -> bool:
|
|
22
|
+
assert isinstance(value, bool), "value must be a bool"
|
|
23
|
+
if not value:
|
|
24
|
+
return value
|
|
25
|
+
from . import __version__
|
|
26
|
+
|
|
27
|
+
assert __version__, "version string must be non-empty"
|
|
28
|
+
typer.echo(__version__)
|
|
29
|
+
raise typer.Exit()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.callback()
|
|
33
|
+
def _cli(
|
|
34
|
+
version: Annotated[
|
|
35
|
+
bool, typer.Option("--version", "-v", callback=_version, is_eager=True)
|
|
36
|
+
] = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Polyglot ubiquitous language linter for codebases and coding agents."""
|
|
39
|
+
assert isinstance(version, bool), "version flag must be a bool"
|
|
40
|
+
assert app is not None, "app must be initialized"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
SKIP = {".git", ".venv", "node_modules", "__pycache__", "target", "dist", "build"}
|
|
44
|
+
DEFAULT_CONFIG = Path("dddlint.yaml")
|
|
45
|
+
|
|
46
|
+
RULE_STYLE: dict[str, str] = {
|
|
47
|
+
"forbidden": "bold red",
|
|
48
|
+
"alias": "bold yellow",
|
|
49
|
+
"drift": "bold cyan",
|
|
50
|
+
"config:forbidden-canonical-clash": "bold red",
|
|
51
|
+
"config:alias-conflict": "bold yellow",
|
|
52
|
+
"config:duplicate-name": "bold cyan",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve(root: Path | None, config: Path | None) -> tuple[Path, Path]:
|
|
57
|
+
assert root is None or isinstance(root, Path), "root must be a Path or None"
|
|
58
|
+
assert config is None or isinstance(config, Path), "config must be a Path or None"
|
|
59
|
+
if root is None:
|
|
60
|
+
root = Path.cwd()
|
|
61
|
+
if config is None:
|
|
62
|
+
config = root / DEFAULT_CONFIG
|
|
63
|
+
if not config.exists():
|
|
64
|
+
config = Path.cwd() / DEFAULT_CONFIG
|
|
65
|
+
return root, config
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _print_findings(findings: list[Finding]) -> None:
|
|
69
|
+
assert findings is not None, "findings must not be None"
|
|
70
|
+
assert isinstance(findings, list), "findings must be a list"
|
|
71
|
+
by_file: dict[Path, list[Finding]] = defaultdict(list)
|
|
72
|
+
for f in findings:
|
|
73
|
+
by_file[f.path].append(f)
|
|
74
|
+
for path, file_findings in by_file.items():
|
|
75
|
+
console.print(f"\n[bold white]{path}[/bold white]")
|
|
76
|
+
console.rule(style="dim")
|
|
77
|
+
for f in file_findings:
|
|
78
|
+
style = RULE_STYLE.get(f.rule, "bold white")
|
|
79
|
+
console.print(
|
|
80
|
+
f" [dim]{f.line:>4}[/dim] [{style}]{f.rule:<10}[/{style}]"
|
|
81
|
+
f" [bold]{f.name}[/bold] [dim]{f.message}[/dim]"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def lint(
|
|
87
|
+
root: Annotated[Path | None, typer.Argument()] = None,
|
|
88
|
+
config: Annotated[Path | None, typer.Option()] = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Lint a codebase for DDD ubiquitous language violations."""
|
|
91
|
+
root, config = _resolve(root, config)
|
|
92
|
+
assert isinstance(root, Path), "resolved root must be a Path"
|
|
93
|
+
assert config.name, "config path must have a name"
|
|
94
|
+
settings = load_config(config)
|
|
95
|
+
extra: dict[str, str] = {}
|
|
96
|
+
collected: list[Definition] = []
|
|
97
|
+
for path in root.rglob("*"):
|
|
98
|
+
if not path.is_file() or SKIP & set(path.parts):
|
|
99
|
+
continue
|
|
100
|
+
language = language_for(path, extra)
|
|
101
|
+
if language is None:
|
|
102
|
+
continue
|
|
103
|
+
collected.extend(definitions(path, language))
|
|
104
|
+
|
|
105
|
+
findings = check_config(settings, config) + check(collected, settings)
|
|
106
|
+
if findings:
|
|
107
|
+
_print_findings(findings)
|
|
108
|
+
n = len(findings)
|
|
109
|
+
console.print(f"\n[bold red]✖ {n} finding{'s' if n != 1 else ''}[/bold red]")
|
|
110
|
+
else:
|
|
111
|
+
console.print("[bold green]✔ no findings[/bold green]")
|
|
112
|
+
raise typer.Exit(1 if findings else 0)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command()
|
|
116
|
+
def html(
|
|
117
|
+
root: Annotated[Path | None, typer.Argument()] = None,
|
|
118
|
+
config: Annotated[Path | None, typer.Option()] = None,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Open an interactive DDD graph in the browser."""
|
|
121
|
+
import tempfile
|
|
122
|
+
import webbrowser
|
|
123
|
+
|
|
124
|
+
from .view import _generate_html
|
|
125
|
+
|
|
126
|
+
root, config = _resolve(root, config)
|
|
127
|
+
assert isinstance(root, Path), "resolved root must be a Path"
|
|
128
|
+
assert config.name, "config path must have a name"
|
|
129
|
+
settings = load_config(config)
|
|
130
|
+
content = _generate_html(settings)
|
|
131
|
+
with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w") as f:
|
|
132
|
+
f.write(content)
|
|
133
|
+
path = f.name
|
|
134
|
+
webbrowser.open(f"file://{path}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def lsp(
|
|
139
|
+
root: Annotated[Path | None, typer.Argument()] = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Start the LSP server (stdio transport)."""
|
|
142
|
+
assert root is None or isinstance(root, Path), "root must be a Path or None"
|
|
143
|
+
from .server import main as server_main
|
|
144
|
+
|
|
145
|
+
assert callable(server_main), "server entrypoint must be callable"
|
|
146
|
+
server_main()
|
dddlint/config.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SynonymGroup(BaseModel):
|
|
8
|
+
canonical: str
|
|
9
|
+
aliases: list[str] = []
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Context(BaseModel):
|
|
13
|
+
name: str
|
|
14
|
+
include: list[str]
|
|
15
|
+
forbidden: list[str] = []
|
|
16
|
+
synonyms: list[SynonymGroup] = []
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Config(BaseModel):
|
|
20
|
+
similarity_threshold: float = 0.85
|
|
21
|
+
enforce_canonical: bool = True
|
|
22
|
+
forbidden: list[str] = []
|
|
23
|
+
synonyms: list[SynonymGroup] = []
|
|
24
|
+
domains: list[Context] = []
|
|
25
|
+
contexts: list[Context] = []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_config(path: Path) -> Config:
|
|
29
|
+
assert path is not None, "path must not be None"
|
|
30
|
+
assert isinstance(path, Path), "path must be a Path object"
|
|
31
|
+
return Config.model_validate(yaml.safe_load(path.read_text()) or {})
|
dddlint/config_check.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from difflib import SequenceMatcher
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .check import Finding
|
|
5
|
+
from .config import Config, Context, SynonymGroup
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _alias_map(synonyms: list[SynonymGroup]) -> dict[str, str]:
|
|
9
|
+
assert synonyms is not None, "synonyms must not be None"
|
|
10
|
+
assert isinstance(synonyms, list), "synonyms must be a list"
|
|
11
|
+
return {alias.lower(): g.canonical.lower() for g in synonyms for alias in g.aliases}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _forbidden_canonical_clashes(config: Config, path: Path) -> list[Finding]:
|
|
15
|
+
assert config is not None, "config must not be None"
|
|
16
|
+
assert isinstance(path, Path), "path must be a Path object"
|
|
17
|
+
forbidden = {t.lower() for t in config.forbidden}
|
|
18
|
+
canonicals = {g.canonical.lower() for g in config.synonyms}
|
|
19
|
+
return [
|
|
20
|
+
Finding(
|
|
21
|
+
path,
|
|
22
|
+
0,
|
|
23
|
+
term,
|
|
24
|
+
"config:forbidden-canonical-clash",
|
|
25
|
+
f"'{term}' is both forbidden and a canonical synonym",
|
|
26
|
+
)
|
|
27
|
+
for term in forbidden & canonicals
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _alias_conflicts(config: Config, path: Path) -> list[Finding]:
|
|
32
|
+
assert config is not None, "config must not be None"
|
|
33
|
+
assert isinstance(path, Path), "path must be a Path object"
|
|
34
|
+
scopes: list[tuple[str, list[SynonymGroup]]] = [("global", config.synonyms)]
|
|
35
|
+
for s in config.domains:
|
|
36
|
+
scopes.append((f"domain:{s.name}", s.synonyms))
|
|
37
|
+
for s in config.contexts:
|
|
38
|
+
scopes.append((f"context:{s.name}", s.synonyms))
|
|
39
|
+
|
|
40
|
+
out: list[Finding] = []
|
|
41
|
+
seen: dict[str, tuple[str, str]] = {}
|
|
42
|
+
for scope_name, synonyms in scopes:
|
|
43
|
+
for alias, canonical in _alias_map(synonyms).items():
|
|
44
|
+
if alias in seen and seen[alias][1] != canonical:
|
|
45
|
+
prev_scope, prev_canonical = seen[alias]
|
|
46
|
+
out.append(
|
|
47
|
+
Finding(
|
|
48
|
+
path,
|
|
49
|
+
0,
|
|
50
|
+
alias,
|
|
51
|
+
"config:alias-conflict",
|
|
52
|
+
f"'{alias}' → '{canonical}' in {scope_name} "
|
|
53
|
+
f"but → '{prev_canonical}' in {prev_scope}",
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
seen[alias] = (scope_name, canonical)
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _duplicate_names(config: Config, path: Path) -> list[Finding]:
|
|
62
|
+
assert config is not None, "config must not be None"
|
|
63
|
+
assert isinstance(path, Path), "path must be a Path object"
|
|
64
|
+
out: list[Finding] = []
|
|
65
|
+
all_scopes: list[Context] = config.domains + config.contexts
|
|
66
|
+
for i, a in enumerate(all_scopes):
|
|
67
|
+
for b in all_scopes[i + 1 :]:
|
|
68
|
+
similarity = SequenceMatcher(None, a.name.lower(), b.name.lower()).ratio()
|
|
69
|
+
if similarity >= config.similarity_threshold:
|
|
70
|
+
out.append(
|
|
71
|
+
Finding(
|
|
72
|
+
path,
|
|
73
|
+
0,
|
|
74
|
+
a.name,
|
|
75
|
+
"config:duplicate-name",
|
|
76
|
+
f"'{a.name}' and '{b.name}' look like the same domain/context",
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check_config(config: Config, path: Path) -> list[Finding]:
|
|
83
|
+
assert config is not None, "config must not be None"
|
|
84
|
+
assert isinstance(path, Path), "path must be a Path object"
|
|
85
|
+
return (
|
|
86
|
+
_forbidden_canonical_clashes(config, path)
|
|
87
|
+
+ _alias_conflicts(config, path)
|
|
88
|
+
+ _duplicate_names(config, path)
|
|
89
|
+
)
|
dddlint/extract.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import tree_sitter_language_pack as tslp
|
|
7
|
+
|
|
8
|
+
DEFINITION_KINDS = frozenset(
|
|
9
|
+
{"Function", "Method", "Class", "Struct", "Interface", "Enum", "Trait"}
|
|
10
|
+
)
|
|
11
|
+
SYMBOL_KINDS = frozenset({"Variable", "Constant"})
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Definition:
|
|
16
|
+
name: str
|
|
17
|
+
kind: str
|
|
18
|
+
path: Path
|
|
19
|
+
line: int
|
|
20
|
+
col: int = 0
|
|
21
|
+
doc: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _kind_name(kind: object) -> str:
|
|
25
|
+
assert kind is not None, "kind must not be None"
|
|
26
|
+
name = str(kind)
|
|
27
|
+
assert name, "kind name must be non-empty"
|
|
28
|
+
return name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _flatten(
|
|
32
|
+
items: Iterable[Any], path: Path, source_lines: list[str], out: list[Definition]
|
|
33
|
+
) -> None:
|
|
34
|
+
assert path is not None, "path must not be None"
|
|
35
|
+
assert isinstance(out, list), "out must be a list"
|
|
36
|
+
stack: list[Any] = list(items)
|
|
37
|
+
while stack:
|
|
38
|
+
item = stack.pop(0)
|
|
39
|
+
kind = _kind_name(item.kind)
|
|
40
|
+
if item.name and kind in DEFINITION_KINDS:
|
|
41
|
+
line = item.span.start_line
|
|
42
|
+
line_text = source_lines[line] if line < len(source_lines) else ""
|
|
43
|
+
col = line_text.find(item.name)
|
|
44
|
+
out.append(Definition(item.name, kind, path, line, max(col, 0), item.doc_comment))
|
|
45
|
+
if item.children:
|
|
46
|
+
stack[:0] = list(item.children)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def language_for(path: Path, extra: dict[str, str]) -> str | None:
|
|
50
|
+
assert isinstance(path, Path), "path must be a Path object"
|
|
51
|
+
assert extra is not None, "extra must not be None"
|
|
52
|
+
return extra.get(path.suffix) or tslp.detect_language_from_path(str(path))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def definitions(path: Path, language: str) -> list[Definition]:
|
|
56
|
+
assert isinstance(path, Path), "path must be a Path object"
|
|
57
|
+
assert language, "language must be non-empty"
|
|
58
|
+
source = path.read_text(errors="ignore")
|
|
59
|
+
config = tslp.ProcessConfig(
|
|
60
|
+
language=language,
|
|
61
|
+
structure=True,
|
|
62
|
+
symbols=True,
|
|
63
|
+
imports=False,
|
|
64
|
+
exports=False,
|
|
65
|
+
comments=False,
|
|
66
|
+
docstrings=True,
|
|
67
|
+
diagnostics=False,
|
|
68
|
+
chunk_max_size=None,
|
|
69
|
+
)
|
|
70
|
+
result = tslp.process(source, config)
|
|
71
|
+
source_lines = source.splitlines()
|
|
72
|
+
out: list[Definition] = []
|
|
73
|
+
_flatten(result.structure, path, source_lines, out)
|
|
74
|
+
for sym in result.symbols:
|
|
75
|
+
kind = _kind_name(sym.kind)
|
|
76
|
+
if kind not in SYMBOL_KINDS:
|
|
77
|
+
continue
|
|
78
|
+
line = sym.span.start_line
|
|
79
|
+
line_text = source_lines[line] if line < len(source_lines) else ""
|
|
80
|
+
col = line_text.find(sym.name)
|
|
81
|
+
out.append(Definition(sym.name, kind, path, line, max(col, 0), sym.doc))
|
|
82
|
+
return out
|
dddlint/mcp_server.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""MCP server for dddlint documentation.
|
|
2
|
+
|
|
3
|
+
Provides documentation access via Model Context Protocol.
|
|
4
|
+
Usage:
|
|
5
|
+
claude mcp add dddlint --transport stdio \\
|
|
6
|
+
python src/dddlint/mcp_server.py
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from mcp.server import Server
|
|
13
|
+
from mcp.server.stdio import stdio_server
|
|
14
|
+
from mcp.types import Resource
|
|
15
|
+
|
|
16
|
+
logging.basicConfig(level=logging.INFO)
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
app = Server("dddlint")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.list_resources()
|
|
23
|
+
async def list_resources() -> list[Resource]:
|
|
24
|
+
"""List available documentation resources."""
|
|
25
|
+
assert app is not None, "Server must be initialized"
|
|
26
|
+
|
|
27
|
+
docs_dir = Path(__file__).parent.parent.parent / "docs"
|
|
28
|
+
assert docs_dir.exists(), "Docs directory must exist"
|
|
29
|
+
|
|
30
|
+
resources = []
|
|
31
|
+
for doc_file in docs_dir.rglob("*.md"):
|
|
32
|
+
relative_path = doc_file.relative_to(docs_dir)
|
|
33
|
+
uri_str = f"doc://dddlint/{relative_path}"
|
|
34
|
+
resources.append(
|
|
35
|
+
Resource(
|
|
36
|
+
uri=uri_str, # type: ignore[arg-type]
|
|
37
|
+
name=str(relative_path),
|
|
38
|
+
mimeType="text/markdown",
|
|
39
|
+
description=f"Documentation: {relative_path}",
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return resources
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.read_resource()
|
|
47
|
+
async def read_resource(uri) -> str: # type: ignore[no-untyped-def]
|
|
48
|
+
"""Read documentation content."""
|
|
49
|
+
uri_str = str(uri)
|
|
50
|
+
assert uri_str is not None, "URI must not be None"
|
|
51
|
+
assert uri_str.startswith("doc://dddlint/"), "URI must be for this package"
|
|
52
|
+
|
|
53
|
+
path = uri_str.replace("doc://dddlint/", "")
|
|
54
|
+
docs_dir = Path(__file__).parent.parent.parent / "docs"
|
|
55
|
+
doc_file = docs_dir / path
|
|
56
|
+
|
|
57
|
+
assert doc_file.exists(), f"Documentation file {path} not found"
|
|
58
|
+
assert doc_file.is_relative_to(docs_dir), "Path must be within docs directory"
|
|
59
|
+
|
|
60
|
+
return doc_file.read_text()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def main() -> None:
|
|
64
|
+
"""Run MCP server via stdio."""
|
|
65
|
+
assert app is not None, "Server must be initialized"
|
|
66
|
+
|
|
67
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
68
|
+
assert read_stream is not None, "Read stream must not be None"
|
|
69
|
+
assert write_stream is not None, "Write stream must not be None"
|
|
70
|
+
await app.run(read_stream, write_stream, app.create_initialization_options())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
import asyncio
|
|
75
|
+
|
|
76
|
+
asyncio.run(main())
|
dddlint/server.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
from lsprotocol import types
|
|
6
|
+
from pygls.lsp.server import LanguageServer
|
|
7
|
+
|
|
8
|
+
from .check import Finding, check
|
|
9
|
+
from .config import Config, load_config
|
|
10
|
+
from .config_check import check_config
|
|
11
|
+
from .extract import Definition, definitions, language_for
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
SKIP = {".git", ".venv", "node_modules", "__pycache__", "target", "dist", "build"}
|
|
16
|
+
DEFAULT_CONFIG = Path("dddlint.yaml")
|
|
17
|
+
|
|
18
|
+
SEVERITY: dict[str, types.DiagnosticSeverity] = {
|
|
19
|
+
"forbidden": types.DiagnosticSeverity.Error,
|
|
20
|
+
"alias": types.DiagnosticSeverity.Warning,
|
|
21
|
+
"drift": types.DiagnosticSeverity.Information,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DddlintServer(LanguageServer):
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
super().__init__("dddlint", "v0.1.0")
|
|
28
|
+
self.ddd_findings: dict[str, list[Finding]] = {}
|
|
29
|
+
assert isinstance(self.ddd_findings, dict), "findings must be a dict"
|
|
30
|
+
assert self.ddd_findings == {}, "findings must start empty"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
server = DddlintServer()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _to_path(uri: str) -> Path:
|
|
37
|
+
assert uri, "uri must be non-empty"
|
|
38
|
+
assert isinstance(uri, str), "uri must be a string"
|
|
39
|
+
return Path(urlparse(uri).path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _scan(ls: DddlintServer) -> None:
|
|
43
|
+
assert ls is not None, "language server must not be None"
|
|
44
|
+
assert isinstance(ls, DddlintServer), "ls must be a DddlintServer"
|
|
45
|
+
root_uri = ls.workspace.root_uri
|
|
46
|
+
if not root_uri:
|
|
47
|
+
return
|
|
48
|
+
root = _to_path(root_uri)
|
|
49
|
+
config_path = root / DEFAULT_CONFIG
|
|
50
|
+
settings = load_config(config_path) if config_path.exists() else Config()
|
|
51
|
+
|
|
52
|
+
extra: dict[str, str] = {}
|
|
53
|
+
collected: list[Definition] = []
|
|
54
|
+
for path in root.rglob("*"):
|
|
55
|
+
if not path.is_file() or SKIP & set(path.parts):
|
|
56
|
+
continue
|
|
57
|
+
lang = language_for(path, extra)
|
|
58
|
+
if lang is None:
|
|
59
|
+
continue
|
|
60
|
+
try:
|
|
61
|
+
collected.extend(definitions(path, lang))
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
logger.debug("failed to extract definitions from %s: %s", path, exc)
|
|
64
|
+
|
|
65
|
+
all_findings: list[Finding] = []
|
|
66
|
+
if config_path.exists():
|
|
67
|
+
all_findings += check_config(settings, config_path)
|
|
68
|
+
all_findings += check(collected, settings)
|
|
69
|
+
by_file: dict[Path, list[Finding]] = {}
|
|
70
|
+
for f in all_findings:
|
|
71
|
+
by_file.setdefault(f.path, []).append(f)
|
|
72
|
+
|
|
73
|
+
published: set[str] = set()
|
|
74
|
+
for path, file_findings in by_file.items():
|
|
75
|
+
uri = path.absolute().as_uri()
|
|
76
|
+
ls.text_document_publish_diagnostics(
|
|
77
|
+
types.PublishDiagnosticsParams(
|
|
78
|
+
uri=uri, diagnostics=[_to_diagnostic(f) for f in file_findings]
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
ls.ddd_findings[uri] = file_findings
|
|
82
|
+
published.add(uri)
|
|
83
|
+
|
|
84
|
+
for uri in list(ls.ddd_findings):
|
|
85
|
+
if uri not in published:
|
|
86
|
+
ls.text_document_publish_diagnostics(
|
|
87
|
+
types.PublishDiagnosticsParams(uri=uri, diagnostics=[])
|
|
88
|
+
)
|
|
89
|
+
del ls.ddd_findings[uri]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _to_diagnostic(f: Finding) -> types.Diagnostic:
|
|
93
|
+
assert f is not None, "finding must not be None"
|
|
94
|
+
assert f.line >= 0, "line must be non-negative"
|
|
95
|
+
start = types.Position(line=f.line, character=f.col)
|
|
96
|
+
end = types.Position(line=f.line, character=f.col + len(f.name))
|
|
97
|
+
return types.Diagnostic(
|
|
98
|
+
range=types.Range(start=start, end=end),
|
|
99
|
+
message=f.message,
|
|
100
|
+
severity=SEVERITY.get(f.rule, types.DiagnosticSeverity.Warning),
|
|
101
|
+
source="dddlint",
|
|
102
|
+
code=f.rule,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@server.feature(types.TEXT_DOCUMENT_DID_OPEN)
|
|
107
|
+
def did_open(ls: DddlintServer, params: types.DidOpenTextDocumentParams) -> None:
|
|
108
|
+
assert ls is not None, "ls must not be None"
|
|
109
|
+
assert params is not None, "params must not be None"
|
|
110
|
+
_scan(ls)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@server.feature(types.TEXT_DOCUMENT_DID_SAVE)
|
|
114
|
+
def did_save(ls: DddlintServer, params: types.DidSaveTextDocumentParams) -> None:
|
|
115
|
+
assert ls is not None, "ls must not be None"
|
|
116
|
+
assert params is not None, "params must not be None"
|
|
117
|
+
_scan(ls)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@server.feature(
|
|
121
|
+
types.TEXT_DOCUMENT_CODE_ACTION,
|
|
122
|
+
types.CodeActionOptions(code_action_kinds=[types.CodeActionKind.QuickFix]),
|
|
123
|
+
)
|
|
124
|
+
def code_action(ls: DddlintServer, params: types.CodeActionParams) -> list[types.CodeAction]:
|
|
125
|
+
assert ls is not None, "ls must not be None"
|
|
126
|
+
assert params is not None, "params must not be None"
|
|
127
|
+
uri = params.text_document.uri
|
|
128
|
+
cursor_line = params.range.start.line
|
|
129
|
+
actions: list[types.CodeAction] = []
|
|
130
|
+
|
|
131
|
+
for f in ls.ddd_findings.get(uri, []):
|
|
132
|
+
if f.line != cursor_line or f.fix is None:
|
|
133
|
+
continue
|
|
134
|
+
actions.append(
|
|
135
|
+
types.CodeAction(
|
|
136
|
+
title=f"Rename '{f.name}' → '{f.fix}' (dddlint: {f.rule})",
|
|
137
|
+
kind=types.CodeActionKind.QuickFix,
|
|
138
|
+
edit=types.WorkspaceEdit(
|
|
139
|
+
changes={
|
|
140
|
+
uri: [
|
|
141
|
+
types.TextEdit(
|
|
142
|
+
range=types.Range(
|
|
143
|
+
start=types.Position(line=f.line, character=f.col),
|
|
144
|
+
end=types.Position(line=f.line, character=f.col + len(f.name)),
|
|
145
|
+
),
|
|
146
|
+
new_text=f.fix,
|
|
147
|
+
)
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return actions
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main() -> None:
|
|
157
|
+
assert server is not None, "server must be initialized"
|
|
158
|
+
assert isinstance(server, DddlintServer), "server must be a DddlintServer"
|
|
159
|
+
server.start_io()
|
dddlint/view.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from .config import Config
|
|
4
|
+
|
|
5
|
+
_COLORS = {
|
|
6
|
+
"global": "#7c3aed",
|
|
7
|
+
"domain": "#2563eb",
|
|
8
|
+
"context": "#0891b2",
|
|
9
|
+
"canonical": "#16a34a",
|
|
10
|
+
"alias": "#374151",
|
|
11
|
+
"forbidden": "#dc2626",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
_RADII = {
|
|
15
|
+
"global": 38,
|
|
16
|
+
"domain": 32,
|
|
17
|
+
"context": 32,
|
|
18
|
+
"canonical": 22,
|
|
19
|
+
"alias": 16,
|
|
20
|
+
"forbidden": 16,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_graph(config: Config) -> dict:
|
|
25
|
+
assert config is not None, "config must not be None"
|
|
26
|
+
assert isinstance(config, Config), "config must be a Config"
|
|
27
|
+
nodes: list[dict] = []
|
|
28
|
+
edges: list[dict] = []
|
|
29
|
+
seen: set[str] = set()
|
|
30
|
+
|
|
31
|
+
def node(id_: str, label: str, kind: str) -> None:
|
|
32
|
+
assert id_, "id_ must be non-empty"
|
|
33
|
+
assert kind in _COLORS, "kind must be a known node type"
|
|
34
|
+
if id_ not in seen:
|
|
35
|
+
nodes.append(
|
|
36
|
+
{"id": id_, "label": label, "type": kind, "color": _COLORS[kind], "r": _RADII[kind]}
|
|
37
|
+
)
|
|
38
|
+
seen.add(id_)
|
|
39
|
+
|
|
40
|
+
def edge(src: str, tgt: str, kind: str = "owns") -> None:
|
|
41
|
+
assert src and tgt, "src and tgt must be non-empty"
|
|
42
|
+
assert kind, "kind must be non-empty"
|
|
43
|
+
edges.append({"source": src, "target": tgt, "kind": kind})
|
|
44
|
+
|
|
45
|
+
node("global", "Global", "global")
|
|
46
|
+
|
|
47
|
+
for term in config.forbidden:
|
|
48
|
+
nid = f"forbidden:{term}"
|
|
49
|
+
node(nid, term, "forbidden")
|
|
50
|
+
edge("global", nid, "forbidden")
|
|
51
|
+
|
|
52
|
+
for g in config.synonyms:
|
|
53
|
+
cid = f"canonical:{g.canonical}"
|
|
54
|
+
node(cid, g.canonical, "canonical")
|
|
55
|
+
edge("global", cid, "owns")
|
|
56
|
+
for alias in g.aliases:
|
|
57
|
+
aid = f"alias:{alias}"
|
|
58
|
+
node(aid, alias, "alias")
|
|
59
|
+
edge(aid, cid, "alias")
|
|
60
|
+
|
|
61
|
+
for scope_list, kind in [(config.domains, "domain"), (config.contexts, "context")]:
|
|
62
|
+
for scope in scope_list:
|
|
63
|
+
sid = f"{kind}:{scope.name}"
|
|
64
|
+
node(sid, scope.name, kind)
|
|
65
|
+
edge("global", sid, "scope")
|
|
66
|
+
for term in scope.forbidden:
|
|
67
|
+
nid = f"forbidden:{sid}:{term}"
|
|
68
|
+
node(nid, term, "forbidden")
|
|
69
|
+
edge(sid, nid, "forbidden")
|
|
70
|
+
for g in scope.synonyms:
|
|
71
|
+
cid = f"canonical:{sid}:{g.canonical}"
|
|
72
|
+
node(cid, g.canonical, "canonical")
|
|
73
|
+
edge(sid, cid, "owns")
|
|
74
|
+
for alias in g.aliases:
|
|
75
|
+
aid = f"alias:{sid}:{alias}"
|
|
76
|
+
node(aid, alias, "alias")
|
|
77
|
+
edge(aid, cid, "alias")
|
|
78
|
+
|
|
79
|
+
return {"nodes": nodes, "edges": edges}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_HTML_TEMPLATE = """\
|
|
83
|
+
<!DOCTYPE html>
|
|
84
|
+
<html lang="en">
|
|
85
|
+
<head>
|
|
86
|
+
<meta charset="UTF-8">
|
|
87
|
+
<title>DDD Graph</title>
|
|
88
|
+
<style>
|
|
89
|
+
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
|
90
|
+
body {{ background: #0d0f17; overflow: hidden; font-family: system-ui, sans-serif; }}
|
|
91
|
+
canvas {{ display: block; }}
|
|
92
|
+
#legend {{
|
|
93
|
+
position: fixed; bottom: 1.5rem; left: 1.5rem;
|
|
94
|
+
display: flex; gap: 1rem; flex-wrap: wrap;
|
|
95
|
+
}}
|
|
96
|
+
.leg {{ display: flex; align-items: center; gap: 0.35rem;
|
|
97
|
+
font-size: 0.72rem; color: #6b7280; }}
|
|
98
|
+
.dot {{ width: 10px; height: 10px; border-radius: 50%; }}
|
|
99
|
+
#title {{
|
|
100
|
+
position: fixed; top: 1.5rem; left: 1.5rem;
|
|
101
|
+
font-size: 1.1rem; font-weight: 700; color: #e2e4f0;
|
|
102
|
+
letter-spacing: -0.03em;
|
|
103
|
+
}}
|
|
104
|
+
#hint {{
|
|
105
|
+
position: fixed; top: 1.5rem; right: 1.5rem;
|
|
106
|
+
font-size: 0.72rem; color: #374151;
|
|
107
|
+
}}
|
|
108
|
+
</style>
|
|
109
|
+
</head>
|
|
110
|
+
<body>
|
|
111
|
+
<canvas id="c"></canvas>
|
|
112
|
+
<div id="title">DDD Graph</div>
|
|
113
|
+
<div id="hint">scroll to zoom · drag to pan · drag nodes</div>
|
|
114
|
+
<div id="legend">
|
|
115
|
+
<div class="leg"><div class="dot" style="background:#7c3aed"></div>global</div>
|
|
116
|
+
<div class="leg"><div class="dot" style="background:#2563eb"></div>domain</div>
|
|
117
|
+
<div class="leg"><div class="dot" style="background:#0891b2"></div>context</div>
|
|
118
|
+
<div class="leg"><div class="dot" style="background:#16a34a"></div>canonical</div>
|
|
119
|
+
<div class="leg"><div class="dot" style="background:#374151;border:1px solid #6b7280"></div>alias</div>
|
|
120
|
+
<div class="leg"><div class="dot" style="background:#dc2626"></div>forbidden</div>
|
|
121
|
+
</div>
|
|
122
|
+
<script>
|
|
123
|
+
const DATA = {data};
|
|
124
|
+
|
|
125
|
+
const canvas = document.getElementById('c');
|
|
126
|
+
const ctx = canvas.getContext('2d');
|
|
127
|
+
let W, H;
|
|
128
|
+
|
|
129
|
+
function resize() {{
|
|
130
|
+
W = canvas.width = window.innerWidth;
|
|
131
|
+
H = canvas.height = window.innerHeight;
|
|
132
|
+
}}
|
|
133
|
+
resize();
|
|
134
|
+
window.addEventListener('resize', resize);
|
|
135
|
+
|
|
136
|
+
// --- state ---
|
|
137
|
+
const nodes = DATA.nodes.map(n => ({{
|
|
138
|
+
...n,
|
|
139
|
+
x: W/2 + (Math.random()-0.5)*300,
|
|
140
|
+
y: H/2 + (Math.random()-0.5)*300,
|
|
141
|
+
vx: 0, vy: 0, pinned: false
|
|
142
|
+
}}));
|
|
143
|
+
const edges = DATA.edges;
|
|
144
|
+
const nodeById = Object.fromEntries(nodes.map(n => [n.id, n]));
|
|
145
|
+
|
|
146
|
+
// --- camera ---
|
|
147
|
+
let cam = {{ x: 0, y: 0, zoom: 1 }};
|
|
148
|
+
|
|
149
|
+
// --- interaction ---
|
|
150
|
+
let drag = null;
|
|
151
|
+
let pan = null;
|
|
152
|
+
|
|
153
|
+
function worldPos(ex, ey) {{
|
|
154
|
+
return {{
|
|
155
|
+
x: (ex - W/2 - cam.x) / cam.zoom,
|
|
156
|
+
y: (ey - H/2 - cam.y) / cam.zoom,
|
|
157
|
+
}};
|
|
158
|
+
}}
|
|
159
|
+
|
|
160
|
+
function hitNode(wx, wy) {{
|
|
161
|
+
for (let i = nodes.length-1; i >= 0; i--) {{
|
|
162
|
+
const n = nodes[i];
|
|
163
|
+
const dx = wx - n.x, dy = wy - n.y;
|
|
164
|
+
if (Math.sqrt(dx*dx+dy*dy) <= n.r + 2) return n;
|
|
165
|
+
}}
|
|
166
|
+
return null;
|
|
167
|
+
}}
|
|
168
|
+
|
|
169
|
+
canvas.addEventListener('mousedown', e => {{
|
|
170
|
+
const w = worldPos(e.clientX, e.clientY);
|
|
171
|
+
const hit = hitNode(w.x, w.y);
|
|
172
|
+
if (hit) {{ drag = {{ node: hit, ox: w.x - hit.x, oy: w.y - hit.y }}; hit.pinned = true; }}
|
|
173
|
+
else pan = {{ ox: e.clientX - cam.x, oy: e.clientY - cam.y }};
|
|
174
|
+
}});
|
|
175
|
+
|
|
176
|
+
canvas.addEventListener('mousemove', e => {{
|
|
177
|
+
if (drag) {{
|
|
178
|
+
const w = worldPos(e.clientX, e.clientY);
|
|
179
|
+
drag.node.x = w.x - drag.ox;
|
|
180
|
+
drag.node.y = w.y - drag.oy;
|
|
181
|
+
drag.node.vx = drag.node.vy = 0;
|
|
182
|
+
}} else if (pan) {{
|
|
183
|
+
cam.x = e.clientX - pan.ox;
|
|
184
|
+
cam.y = e.clientY - pan.oy;
|
|
185
|
+
}}
|
|
186
|
+
}});
|
|
187
|
+
|
|
188
|
+
canvas.addEventListener('mouseup', () => {{ drag = pan = null; }});
|
|
189
|
+
|
|
190
|
+
canvas.addEventListener('wheel', e => {{
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
const factor = e.deltaY < 0 ? 1.1 : 0.91;
|
|
193
|
+
cam.zoom = Math.max(0.2, Math.min(4, cam.zoom * factor));
|
|
194
|
+
}}, {{ passive: false }});
|
|
195
|
+
|
|
196
|
+
// --- physics ---
|
|
197
|
+
const REPEL = 4000;
|
|
198
|
+
const SPRING = 0.04;
|
|
199
|
+
const REST = {{
|
|
200
|
+
owns: 100, scope: 140, alias: 80, forbidden: 80, default: 110
|
|
201
|
+
}};
|
|
202
|
+
const DAMP = 0.82;
|
|
203
|
+
const CENTER = 0.005;
|
|
204
|
+
|
|
205
|
+
function tick() {{
|
|
206
|
+
// repulsion
|
|
207
|
+
for (let i = 0; i < nodes.length; i++) {{
|
|
208
|
+
for (let j = i+1; j < nodes.length; j++) {{
|
|
209
|
+
const a = nodes[i], b = nodes[j];
|
|
210
|
+
let dx = b.x - a.x, dy = b.y - a.y;
|
|
211
|
+
const d2 = dx*dx + dy*dy || 1;
|
|
212
|
+
const f = REPEL / d2;
|
|
213
|
+
const inv = 1 / Math.sqrt(d2);
|
|
214
|
+
dx *= inv; dy *= inv;
|
|
215
|
+
a.vx -= f*dx; a.vy -= f*dy;
|
|
216
|
+
b.vx += f*dx; b.vy += f*dy;
|
|
217
|
+
}}
|
|
218
|
+
}}
|
|
219
|
+
|
|
220
|
+
// springs
|
|
221
|
+
for (const e of edges) {{
|
|
222
|
+
const a = nodeById[e.source], b = nodeById[e.target];
|
|
223
|
+
if (!a || !b) continue;
|
|
224
|
+
let dx = b.x - a.x, dy = b.y - a.y;
|
|
225
|
+
const d = Math.sqrt(dx*dx+dy*dy) || 1;
|
|
226
|
+
const rest = REST[e.kind] ?? REST.default;
|
|
227
|
+
const f = (d - rest) * SPRING;
|
|
228
|
+
dx /= d; dy /= d;
|
|
229
|
+
a.vx += f*dx; a.vy += f*dy;
|
|
230
|
+
b.vx -= f*dx; b.vy -= f*dy;
|
|
231
|
+
}}
|
|
232
|
+
|
|
233
|
+
// center gravity
|
|
234
|
+
for (const n of nodes) {{
|
|
235
|
+
n.vx += (0 - n.x) * CENTER;
|
|
236
|
+
n.vy += (0 - n.y) * CENTER;
|
|
237
|
+
}}
|
|
238
|
+
|
|
239
|
+
// integrate
|
|
240
|
+
for (const n of nodes) {{
|
|
241
|
+
if (n.pinned && drag?.node === n) continue;
|
|
242
|
+
n.vx *= DAMP; n.vy *= DAMP;
|
|
243
|
+
n.x += n.vx; n.y += n.vy;
|
|
244
|
+
}}
|
|
245
|
+
}}
|
|
246
|
+
|
|
247
|
+
// --- render ---
|
|
248
|
+
function hexAlpha(hex, a) {{
|
|
249
|
+
const r = parseInt(hex.slice(1,3),16);
|
|
250
|
+
const g = parseInt(hex.slice(3,5),16);
|
|
251
|
+
const b = parseInt(hex.slice(5,7),16);
|
|
252
|
+
return `rgba(${{r}},${{g}},${{b}},${{a}})`;
|
|
253
|
+
}}
|
|
254
|
+
|
|
255
|
+
function draw() {{
|
|
256
|
+
ctx.clearRect(0, 0, W, H);
|
|
257
|
+
ctx.save();
|
|
258
|
+
ctx.translate(W/2 + cam.x, H/2 + cam.y);
|
|
259
|
+
ctx.scale(cam.zoom, cam.zoom);
|
|
260
|
+
|
|
261
|
+
// edges
|
|
262
|
+
for (const e of edges) {{
|
|
263
|
+
const a = nodeById[e.source], b = nodeById[e.target];
|
|
264
|
+
if (!a || !b) continue;
|
|
265
|
+
|
|
266
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
267
|
+
const d = Math.sqrt(dx*dx+dy*dy) || 1;
|
|
268
|
+
const ux = dx/d, uy = dy/d;
|
|
269
|
+
const sx = a.x + ux*a.r, sy = a.y + uy*a.r;
|
|
270
|
+
const ex = b.x - ux*b.r, ey = b.y - uy*b.r;
|
|
271
|
+
|
|
272
|
+
ctx.beginPath();
|
|
273
|
+
if (e.kind === 'alias') {{
|
|
274
|
+
ctx.setLineDash([4, 4]);
|
|
275
|
+
ctx.strokeStyle = '#374151';
|
|
276
|
+
ctx.lineWidth = 1;
|
|
277
|
+
}} else if (e.kind === 'forbidden') {{
|
|
278
|
+
ctx.setLineDash([]);
|
|
279
|
+
ctx.strokeStyle = hexAlpha('#dc2626', 0.5);
|
|
280
|
+
ctx.lineWidth = 1;
|
|
281
|
+
}} else if (e.kind === 'scope') {{
|
|
282
|
+
ctx.setLineDash([]);
|
|
283
|
+
ctx.strokeStyle = '#252838';
|
|
284
|
+
ctx.lineWidth = 1.5;
|
|
285
|
+
}} else {{
|
|
286
|
+
ctx.setLineDash([]);
|
|
287
|
+
ctx.strokeStyle = '#1e2130';
|
|
288
|
+
ctx.lineWidth = 1.5;
|
|
289
|
+
}}
|
|
290
|
+
ctx.moveTo(sx, sy);
|
|
291
|
+
ctx.lineTo(ex, ey);
|
|
292
|
+
ctx.stroke();
|
|
293
|
+
|
|
294
|
+
// arrowhead on alias edges
|
|
295
|
+
if (e.kind === 'alias') {{
|
|
296
|
+
ctx.setLineDash([]);
|
|
297
|
+
ctx.fillStyle = '#374151';
|
|
298
|
+
const angle = Math.atan2(ey-sy, ex-sx);
|
|
299
|
+
ctx.save();
|
|
300
|
+
ctx.translate(ex, ey);
|
|
301
|
+
ctx.rotate(angle);
|
|
302
|
+
ctx.beginPath();
|
|
303
|
+
ctx.moveTo(0, 0);
|
|
304
|
+
ctx.lineTo(-7, -3.5);
|
|
305
|
+
ctx.lineTo(-7, 3.5);
|
|
306
|
+
ctx.closePath();
|
|
307
|
+
ctx.fill();
|
|
308
|
+
ctx.restore();
|
|
309
|
+
}}
|
|
310
|
+
}}
|
|
311
|
+
ctx.setLineDash([]);
|
|
312
|
+
|
|
313
|
+
// nodes
|
|
314
|
+
for (const n of nodes) {{
|
|
315
|
+
// glow for scopes
|
|
316
|
+
if (['global','domain','context'].includes(n.type)) {{
|
|
317
|
+
const g = ctx.createRadialGradient(n.x, n.y, n.r*0.5, n.x, n.y, n.r*2);
|
|
318
|
+
g.addColorStop(0, hexAlpha(n.color, 0.15));
|
|
319
|
+
g.addColorStop(1, hexAlpha(n.color, 0));
|
|
320
|
+
ctx.beginPath();
|
|
321
|
+
ctx.arc(n.x, n.y, n.r*2, 0, Math.PI*2);
|
|
322
|
+
ctx.fillStyle = g;
|
|
323
|
+
ctx.fill();
|
|
324
|
+
}}
|
|
325
|
+
|
|
326
|
+
// circle
|
|
327
|
+
ctx.beginPath();
|
|
328
|
+
ctx.arc(n.x, n.y, n.r, 0, Math.PI*2);
|
|
329
|
+
ctx.fillStyle = hexAlpha(n.color, n.type === 'alias' ? 0.25 : 0.18);
|
|
330
|
+
ctx.fill();
|
|
331
|
+
ctx.strokeStyle = n.color;
|
|
332
|
+
ctx.lineWidth = n.type === 'alias' ? 1 : 1.5;
|
|
333
|
+
ctx.stroke();
|
|
334
|
+
|
|
335
|
+
// label
|
|
336
|
+
const fs = n.type === 'global' ? 13 :
|
|
337
|
+
n.type === 'domain' || n.type === 'context' ? 11 : 10;
|
|
338
|
+
ctx.font = `${{['global','domain','context'].includes(n.type) ? 600 : 500}} ${{fs}}px system-ui`;
|
|
339
|
+
ctx.fillStyle = n.type === 'alias' ? '#6b7280' : '#e2e4f0';
|
|
340
|
+
ctx.textAlign = 'center';
|
|
341
|
+
ctx.textBaseline = 'middle';
|
|
342
|
+
ctx.fillText(n.label, n.x, n.y);
|
|
343
|
+
}}
|
|
344
|
+
|
|
345
|
+
ctx.restore();
|
|
346
|
+
}}
|
|
347
|
+
|
|
348
|
+
function loop() {{
|
|
349
|
+
for (let i = 0; i < 3; i++) tick();
|
|
350
|
+
draw();
|
|
351
|
+
requestAnimationFrame(loop);
|
|
352
|
+
}}
|
|
353
|
+
loop();
|
|
354
|
+
</script>
|
|
355
|
+
</body>
|
|
356
|
+
</html>
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _generate_html(config: Config) -> str:
|
|
361
|
+
assert config is not None, "config must not be None"
|
|
362
|
+
assert isinstance(config, Config), "config must be a Config"
|
|
363
|
+
graph = _build_graph(config)
|
|
364
|
+
return _HTML_TEMPLATE.format(data=json.dumps(graph))
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dddlint
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Polyglot ubiquitous language linter for codebases and coding agents
|
|
5
|
+
Project-URL: Repository, https://github.com/benomahony/dddlint
|
|
6
|
+
Author: benomahony
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: lsprotocol>=2025.0.0
|
|
9
|
+
Requires-Dist: mcp>=1.28.1
|
|
10
|
+
Requires-Dist: pydantic>=2.13.4
|
|
11
|
+
Requires-Dist: pygls>=2.1.1
|
|
12
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
13
|
+
Requires-Dist: rich>=15.0.0
|
|
14
|
+
Requires-Dist: tree-sitter-language-pack>=1.8.1
|
|
15
|
+
Requires-Dist: typer>=0.26.2
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# dddlint
|
|
19
|
+
|
|
20
|
+
Polyglot ubiquitous language linter. Reads class, function, method, and type names across **306 languages** and enforces them against a domain vocabulary — banned terms, non-canonical synonyms, and one concept spelled multiple ways.
|
|
21
|
+
|
|
22
|
+
Works with any language tree-sitter recognises, without per-language queries. Slots into pre-commit hooks, CI pipelines, and coding agent loops via a non-zero exit code on findings. Ships with an LSP server for inline editor diagnostics and rename code actions.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
uv add dddlint
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
# lint current directory (looks for dddlint.yaml)
|
|
34
|
+
dddlint lint
|
|
35
|
+
|
|
36
|
+
# lint a specific path
|
|
37
|
+
dddlint lint src/
|
|
38
|
+
|
|
39
|
+
# open an interactive language graph in the browser
|
|
40
|
+
dddlint html
|
|
41
|
+
|
|
42
|
+
# start the LSP server (stdio)
|
|
43
|
+
dddlint lsp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Config
|
|
47
|
+
|
|
48
|
+
Place `dddlint.yaml` at the project root. When a path is passed to `lint` or `html`, the config is looked up in that directory first, then falls back to the current working directory.
|
|
49
|
+
|
|
50
|
+
```yaml
|
|
51
|
+
similarity_threshold: 0.85 # Jaccard threshold for drift detection
|
|
52
|
+
enforce_canonical: true # flag alias terms in addition to forbidden ones
|
|
53
|
+
|
|
54
|
+
# terms that must never appear in a definition name
|
|
55
|
+
forbidden:
|
|
56
|
+
- util
|
|
57
|
+
- helper
|
|
58
|
+
- manager
|
|
59
|
+
|
|
60
|
+
# canonical terms and their aliases
|
|
61
|
+
synonyms:
|
|
62
|
+
- canonical: customer
|
|
63
|
+
aliases: [client, user, accountholder]
|
|
64
|
+
- canonical: order
|
|
65
|
+
aliases: [purchase, transaction]
|
|
66
|
+
|
|
67
|
+
# high-level business domains
|
|
68
|
+
domains:
|
|
69
|
+
- name: commerce
|
|
70
|
+
include: ["**/commerce/**"]
|
|
71
|
+
synonyms:
|
|
72
|
+
- canonical: order
|
|
73
|
+
aliases: [purchase]
|
|
74
|
+
|
|
75
|
+
# bounded contexts — same structure as domains, applied after (context wins on conflict)
|
|
76
|
+
contexts:
|
|
77
|
+
- name: billing
|
|
78
|
+
include: ["**/billing/**"]
|
|
79
|
+
forbidden: [discount]
|
|
80
|
+
synonyms:
|
|
81
|
+
- canonical: invoice
|
|
82
|
+
aliases: [bill, statement]
|
|
83
|
+
|
|
84
|
+
# register file extensions for languages not auto-detected
|
|
85
|
+
languages:
|
|
86
|
+
svelte:
|
|
87
|
+
extensions: [".svelte"]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Global rules apply everywhere. Domain rules apply to matching paths. Context rules apply after domains, so a context can override a domain synonym.
|
|
91
|
+
|
|
92
|
+
## Rules
|
|
93
|
+
|
|
94
|
+
| Rule | Severity | Description |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `forbidden` | error | A definition name contains a banned term |
|
|
97
|
+
| `alias` | warning | A definition uses a non-canonical synonym — includes a rename suggestion |
|
|
98
|
+
| `drift` | info | The same concept is spelled multiple ways across the codebase |
|
|
99
|
+
| `config:forbidden-canonical-clash` | error | A term is both forbidden and a canonical synonym |
|
|
100
|
+
| `config:alias-conflict` | warning | The same alias maps to different canonicals in different scopes |
|
|
101
|
+
| `config:duplicate-name` | info | Two domains or contexts have suspiciously similar names |
|
|
102
|
+
|
|
103
|
+
Config rules are checked against `dddlint.yaml` itself on every run.
|
|
104
|
+
|
|
105
|
+
## LSP
|
|
106
|
+
|
|
107
|
+
The LSP server publishes diagnostics on file open and save, scanning the entire workspace each time so cross-file drift is always caught. Alias findings include a code action to rename the identifier to the canonical term with case preserved (`ClientRepo` → `CustomerRepo`, `get_client` → `get_customer`).
|
|
108
|
+
|
|
109
|
+
**Neovim** — add to `init.lua`:
|
|
110
|
+
|
|
111
|
+
```lua
|
|
112
|
+
vim.api.nvim_create_autocmd("BufReadPost", {
|
|
113
|
+
callback = function(args)
|
|
114
|
+
local root = vim.fs.root(args.buf, { "dddlint.yaml" })
|
|
115
|
+
if root then
|
|
116
|
+
vim.lsp.start({
|
|
117
|
+
name = "dddlint",
|
|
118
|
+
cmd = { "dddlint", "lsp" },
|
|
119
|
+
root_dir = root,
|
|
120
|
+
}, { bufnr = args.buf })
|
|
121
|
+
end
|
|
122
|
+
end,
|
|
123
|
+
})
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The autocmd fires on every buffer, attaches only when `dddlint.yaml` is found, and is language-agnostic — no filetype list required.
|
|
127
|
+
|
|
128
|
+
**VS Code** — via a generic LSP client extension:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"lsp.servers": {
|
|
133
|
+
"dddlint": {
|
|
134
|
+
"command": ["uvx", "dddlint", "lsp"],
|
|
135
|
+
"filetypes": ["*"]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Helix** — `.helix/languages.toml`:
|
|
142
|
+
|
|
143
|
+
```toml
|
|
144
|
+
[language-server.dddlint]
|
|
145
|
+
command = "dddlint"
|
|
146
|
+
args = ["lsp"]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Language support
|
|
150
|
+
|
|
151
|
+
Extraction is powered by [`tree-sitter-language-pack`](https://github.com/Goldziher/tree-sitter-language-pack), which covers 306 languages including:
|
|
152
|
+
|
|
153
|
+
Ada · Agda · Arduino · Bash · C · C++ · C# · Clojure · COBOL · Crystal · CSS · D · Dart · Dockerfile · Elixir · Elm · Erlang · F# · Fortran · GDScript · GLSL · Go · GraphQL · Groovy · Hack · Haskell · HCL · HTML · Java · JavaScript · Julia · Kotlin · Lean · Lua · MATLAB · Mojo · Nix · OCaml · Odin · Pascal · Perl · PHP · PowerShell · Prolog · Python · R · Racket · Ruby · Rust · Scala · Scheme · Solidity · SQL · Svelte · Swift · Terraform · TLA+ · TOML · TypeScript · V · VHDL · Vim · Vue · WebAssembly · XML · YAML · Zig — and [243 more](https://github.com/Goldziher/tree-sitter-language-pack).
|
|
154
|
+
|
|
155
|
+
## CI
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
# .github/workflows/dddlint.yml
|
|
159
|
+
- run: uvx dddlint lint
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Exit code is `0` when clean, `1` when findings exist.
|
|
163
|
+
|
|
164
|
+
## Pre-commit
|
|
165
|
+
|
|
166
|
+
```yaml
|
|
167
|
+
# .pre-commit-config.yaml
|
|
168
|
+
repos:
|
|
169
|
+
- repo: local
|
|
170
|
+
hooks:
|
|
171
|
+
- id: dddlint
|
|
172
|
+
name: dddlint
|
|
173
|
+
entry: dddlint lint
|
|
174
|
+
language: python
|
|
175
|
+
pass_filenames: false
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Offline use
|
|
179
|
+
|
|
180
|
+
The language pack downloads parsers on first use. For CI or air-gapped runs, warm the cache in the image:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
python -c "import tree_sitter_language_pack as t; t.download_all()"
|
|
184
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
dddlint/__init__.py,sha256=YvuYzWnKtqBb-IqG8HAu-nhIYAsgj9Vmc_b9o7vO-js,22
|
|
2
|
+
dddlint/check.py,sha256=paFI81hm361vk-yvamyog98-W-YX0II7en0FMVi60Dg,4596
|
|
3
|
+
dddlint/cli.py,sha256=s4K4xxzux8PzLBxQQeZN1vi804DEgTbPwJeJKXbtoN0,4819
|
|
4
|
+
dddlint/config.py,sha256=lEmM6BOyLIN696PYj1DiW2vpkADfuB9D6toEr6f2NrY,747
|
|
5
|
+
dddlint/config_check.py,sha256=ndpIDdX5OcJ2jyUnu8TJ0jAdspbRv_qZxpEYVn8rLV0,3375
|
|
6
|
+
dddlint/extract.py,sha256=dmVw_rhOTOtUdvfWwdEt74zegn0Dd-Jgxdm_syLiDU0,2663
|
|
7
|
+
dddlint/mcp_server.py,sha256=26frdntTvpNeW5NcYUhmJENFOdC3NbG7FLlBqfnkVOA,2311
|
|
8
|
+
dddlint/server.py,sha256=lmIe5iCH_6B6GYR0dYo05tK9m2F7AViw_6NmU6nxcrY,5525
|
|
9
|
+
dddlint/view.py,sha256=HrJ929xtu21d287n8ZlzF6TljPbIRHO7heSKFvLV4DQ,10382
|
|
10
|
+
dddlint-0.1.2.dist-info/METADATA,sha256=jNxkz2jPWBgQhm2RvZNCDs-td7c-rzo3ujx25rx-jOQ,5782
|
|
11
|
+
dddlint-0.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
dddlint-0.1.2.dist-info/entry_points.txt,sha256=wkXDoWuNSP2ucX8MlVBQQYBumvCKPqKMcV_fXTNwskk,78
|
|
13
|
+
dddlint-0.1.2.dist-info/RECORD,,
|