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 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 {})
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ dddlint = dddlint.cli:app
3
+ dddlint-lsp = dddlint.server:main