jeseki-code-quality 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jeseki_code_quality-0.1.0/.gitignore +12 -0
- jeseki_code_quality-0.1.0/.python-version +1 -0
- jeseki_code_quality-0.1.0/PKG-INFO +25 -0
- jeseki_code_quality-0.1.0/README.md +17 -0
- jeseki_code_quality-0.1.0/code_quality/__init__.py +2 -0
- jeseki_code_quality-0.1.0/code_quality/cli.py +66 -0
- jeseki_code_quality-0.1.0/code_quality/report.py +835 -0
- jeseki_code_quality-0.1.0/main.py +5 -0
- jeseki_code_quality-0.1.0/pyproject.toml +20 -0
- jeseki_code_quality-0.1.0/uv.lock +99 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jeseki-code-quality
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate an offline HTML code quality report.
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Requires-Dist: typer>=0.26.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# code-quality
|
|
10
|
+
|
|
11
|
+
生成离线 HTML 代码质量报告的自用 CLI。
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv tool install jeseki-code-quality
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv run code-quality
|
|
19
|
+
uv run cq
|
|
20
|
+
uv run code-quality --root /path/to/project --output /path/to/report.html
|
|
21
|
+
uv run cq -r /path/to/project -o /path/to/report.html -t
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
默认扫描当前工作目录下的 `src/server` 和 `src/client`,并输出到
|
|
25
|
+
`<root>/tmp/code_quality_report.html`。
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# code-quality
|
|
2
|
+
|
|
3
|
+
生成离线 HTML 代码质量报告的自用 CLI。
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv tool install jeseki-code-quality
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv run code-quality
|
|
11
|
+
uv run cq
|
|
12
|
+
uv run code-quality --root /path/to/project --output /path/to/report.html
|
|
13
|
+
uv run cq -r /path/to/project -o /path/to/report.html -t
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
默认扫描当前工作目录下的 `src/server` 和 `src/client`,并输出到
|
|
17
|
+
`<root>/tmp/code_quality_report.html`。
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Typer CLI for generating code quality reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from code_quality.report import build_report, format_score, write_report
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
add_completion=False,
|
|
15
|
+
help="生成代码质量 HTML 报告。",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def main(
|
|
21
|
+
root: Annotated[
|
|
22
|
+
Path,
|
|
23
|
+
typer.Option(
|
|
24
|
+
"--root",
|
|
25
|
+
"-r",
|
|
26
|
+
file_okay=False,
|
|
27
|
+
dir_okay=True,
|
|
28
|
+
resolve_path=True,
|
|
29
|
+
help="项目根目录;默认扫描当前工作目录。",
|
|
30
|
+
),
|
|
31
|
+
] = Path.cwd(),
|
|
32
|
+
output: Annotated[
|
|
33
|
+
Path | None,
|
|
34
|
+
typer.Option(
|
|
35
|
+
"--output",
|
|
36
|
+
"-o",
|
|
37
|
+
file_okay=True,
|
|
38
|
+
dir_okay=False,
|
|
39
|
+
writable=True,
|
|
40
|
+
resolve_path=True,
|
|
41
|
+
help="HTML 报告输出路径;默认写入 <root>/tmp/code_quality_report.html。",
|
|
42
|
+
),
|
|
43
|
+
] = None,
|
|
44
|
+
include_tests: Annotated[
|
|
45
|
+
bool,
|
|
46
|
+
typer.Option(
|
|
47
|
+
"--include-tests",
|
|
48
|
+
"-t",
|
|
49
|
+
help="让测试文件参与评分;默认仅展示测试文件但不参与平均分。",
|
|
50
|
+
),
|
|
51
|
+
] = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
output_path = output or root / "tmp" / "code_quality_report.html"
|
|
54
|
+
report = build_report(root, include_tests=include_tests)
|
|
55
|
+
written_path = write_report(report, output_path)
|
|
56
|
+
|
|
57
|
+
typer.echo(f"代码质量报告已生成: {written_path}")
|
|
58
|
+
typer.echo(
|
|
59
|
+
f"系统分数: {format_score(report.system.score)} | "
|
|
60
|
+
f"后端: {format_score(report.backend.score)} | "
|
|
61
|
+
f"前端: {format_score(report.frontend.score)}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
app()
|
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Generate an offline HTML code quality report based on effective LOC."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import html
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import tokenize
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from statistics import mean
|
|
16
|
+
from typing import Iterable, Literal
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
GroupName = Literal["backend", "frontend"]
|
|
20
|
+
ScoreStatus = Literal["healthy", "warning", "danger"]
|
|
21
|
+
|
|
22
|
+
FRONTEND_EXTENSIONS = {".ts", ".tsx", ".js", ".jsx", ".css"}
|
|
23
|
+
IGNORED_DIR_NAMES = {
|
|
24
|
+
".git",
|
|
25
|
+
".mypy_cache",
|
|
26
|
+
".pytest_cache",
|
|
27
|
+
".ruff_cache",
|
|
28
|
+
".venv",
|
|
29
|
+
"__pycache__",
|
|
30
|
+
"data",
|
|
31
|
+
"dist",
|
|
32
|
+
"dist-ssr",
|
|
33
|
+
"logs",
|
|
34
|
+
"node_modules",
|
|
35
|
+
"venv",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class FileScore:
|
|
41
|
+
path: Path
|
|
42
|
+
relative_path: str
|
|
43
|
+
group: GroupName
|
|
44
|
+
effective_lines: int
|
|
45
|
+
score: float
|
|
46
|
+
status: ScoreStatus
|
|
47
|
+
included_in_score: bool
|
|
48
|
+
is_test: bool
|
|
49
|
+
warning: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class GroupScore:
|
|
54
|
+
name: GroupName | Literal["system"]
|
|
55
|
+
label: str
|
|
56
|
+
score: float | None
|
|
57
|
+
file_count: int
|
|
58
|
+
included_count: int
|
|
59
|
+
healthy_count: int
|
|
60
|
+
warning_count: int
|
|
61
|
+
danger_count: int
|
|
62
|
+
total_effective_lines: int
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class QualityReport:
|
|
67
|
+
root: Path
|
|
68
|
+
generated_at: datetime
|
|
69
|
+
include_tests: bool
|
|
70
|
+
files: list[FileScore]
|
|
71
|
+
backend: GroupScore
|
|
72
|
+
frontend: GroupScore
|
|
73
|
+
system: GroupScore
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def has_ignored_parent(path: Path) -> bool:
|
|
77
|
+
return any(part in IGNORED_DIR_NAMES or part.startswith("tmp") for part in path.parts)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_test_file(path: Path) -> bool:
|
|
81
|
+
name = path.name
|
|
82
|
+
return (
|
|
83
|
+
"tests" in path.parts
|
|
84
|
+
or "__tests__" in path.parts
|
|
85
|
+
or name.startswith("test_")
|
|
86
|
+
or name.endswith("_test.py")
|
|
87
|
+
or ".test." in name
|
|
88
|
+
or ".spec." in name
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def python_docstring_lines(source: str) -> set[int]:
|
|
93
|
+
try:
|
|
94
|
+
tree = ast.parse(source)
|
|
95
|
+
except SyntaxError:
|
|
96
|
+
return set()
|
|
97
|
+
|
|
98
|
+
spans: set[int] = set()
|
|
99
|
+
|
|
100
|
+
for node in ast.walk(tree):
|
|
101
|
+
if not isinstance(
|
|
102
|
+
node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
|
|
103
|
+
):
|
|
104
|
+
continue
|
|
105
|
+
body = getattr(node, "body", [])
|
|
106
|
+
if not body:
|
|
107
|
+
continue
|
|
108
|
+
first = body[0]
|
|
109
|
+
if not isinstance(first, ast.Expr):
|
|
110
|
+
continue
|
|
111
|
+
value = first.value
|
|
112
|
+
if not (isinstance(value, ast.Constant) and isinstance(value.value, str)):
|
|
113
|
+
continue
|
|
114
|
+
end_lineno = getattr(first, "end_lineno", first.lineno)
|
|
115
|
+
spans.update(range(first.lineno, end_lineno + 1))
|
|
116
|
+
|
|
117
|
+
return spans
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def count_python_effective_lines(path: Path) -> tuple[int, str | None]:
|
|
121
|
+
source = path.read_text(encoding="utf-8")
|
|
122
|
+
docstring_lines = python_docstring_lines(source)
|
|
123
|
+
lines: set[int] = set()
|
|
124
|
+
warning = None
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
tokens = tokenize.generate_tokens(io.StringIO(source).readline)
|
|
128
|
+
for token in tokens:
|
|
129
|
+
if token.type in {
|
|
130
|
+
tokenize.COMMENT,
|
|
131
|
+
tokenize.DEDENT,
|
|
132
|
+
tokenize.ENCODING,
|
|
133
|
+
tokenize.ENDMARKER,
|
|
134
|
+
tokenize.INDENT,
|
|
135
|
+
tokenize.NL,
|
|
136
|
+
tokenize.NEWLINE,
|
|
137
|
+
}:
|
|
138
|
+
continue
|
|
139
|
+
start_line, _ = token.start
|
|
140
|
+
end_line, _ = token.end
|
|
141
|
+
for line_number in range(start_line, end_line + 1):
|
|
142
|
+
if line_number not in docstring_lines:
|
|
143
|
+
lines.add(line_number)
|
|
144
|
+
except (SyntaxError, tokenize.TokenError, UnicodeDecodeError) as exc:
|
|
145
|
+
warning = f"Python tokenization fallback: {exc}"
|
|
146
|
+
return count_simple_effective_lines(source.splitlines()), warning
|
|
147
|
+
|
|
148
|
+
return len(lines), warning
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def count_simple_effective_lines(lines: Iterable[str]) -> int:
|
|
152
|
+
return sum(1 for line in lines if line.strip() and not line.lstrip().startswith("#"))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def count_web_effective_lines(path: Path) -> tuple[int, str | None]:
|
|
156
|
+
source = path.read_text(encoding="utf-8")
|
|
157
|
+
line_has_code = [False for _ in source.splitlines()]
|
|
158
|
+
if source and source.endswith("\n"):
|
|
159
|
+
# splitlines() intentionally omits the final empty line.
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
line_index = 0
|
|
163
|
+
in_block_comment = False
|
|
164
|
+
in_string: str | None = None
|
|
165
|
+
escaped = False
|
|
166
|
+
line_comment_enabled = path.suffix != ".css"
|
|
167
|
+
i = 0
|
|
168
|
+
|
|
169
|
+
while i < len(source):
|
|
170
|
+
char = source[i]
|
|
171
|
+
next_char = source[i + 1] if i + 1 < len(source) else ""
|
|
172
|
+
|
|
173
|
+
if char == "\n":
|
|
174
|
+
line_index += 1
|
|
175
|
+
if in_string != "`":
|
|
176
|
+
in_string = None
|
|
177
|
+
escaped = False
|
|
178
|
+
i += 1
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
if line_index >= len(line_has_code):
|
|
182
|
+
line_has_code.append(False)
|
|
183
|
+
|
|
184
|
+
if in_block_comment:
|
|
185
|
+
if char == "*" and next_char == "/":
|
|
186
|
+
in_block_comment = False
|
|
187
|
+
i += 2
|
|
188
|
+
else:
|
|
189
|
+
i += 1
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
if in_string:
|
|
193
|
+
if not char.isspace():
|
|
194
|
+
line_has_code[line_index] = True
|
|
195
|
+
if escaped:
|
|
196
|
+
escaped = False
|
|
197
|
+
elif char == "\\":
|
|
198
|
+
escaped = True
|
|
199
|
+
elif char == in_string:
|
|
200
|
+
in_string = None
|
|
201
|
+
i += 1
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
if char == "/" and next_char == "*":
|
|
205
|
+
in_block_comment = True
|
|
206
|
+
i += 2
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
if line_comment_enabled and char == "/" and next_char == "/":
|
|
210
|
+
while i < len(source) and source[i] != "\n":
|
|
211
|
+
i += 1
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
if char in {"'", '"', "`"}:
|
|
215
|
+
in_string = char
|
|
216
|
+
line_has_code[line_index] = True
|
|
217
|
+
i += 1
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
if not char.isspace():
|
|
221
|
+
line_has_code[line_index] = True
|
|
222
|
+
|
|
223
|
+
i += 1
|
|
224
|
+
|
|
225
|
+
return sum(1 for has_code in line_has_code if has_code), None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def linear_score(effective_lines: int, healthy_limit: int, danger_limit: int) -> float:
|
|
229
|
+
if effective_lines <= healthy_limit:
|
|
230
|
+
return 100
|
|
231
|
+
if effective_lines >= danger_limit:
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
score = (danger_limit - effective_lines) / (danger_limit - healthy_limit) * 100
|
|
235
|
+
return round(score, 1)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def score_file(group: GroupName, effective_lines: int) -> tuple[float, ScoreStatus]:
|
|
239
|
+
if group == "backend":
|
|
240
|
+
score = linear_score(effective_lines, healthy_limit=300, danger_limit=600)
|
|
241
|
+
else:
|
|
242
|
+
score = linear_score(effective_lines, healthy_limit=600, danger_limit=1000)
|
|
243
|
+
|
|
244
|
+
if score >= 100:
|
|
245
|
+
return score, "healthy"
|
|
246
|
+
if score <= 0:
|
|
247
|
+
return score, "danger"
|
|
248
|
+
return score, "warning"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def iter_source_files(root: Path) -> Iterable[tuple[Path, GroupName]]:
|
|
252
|
+
backend_root = root / "src" / "server"
|
|
253
|
+
frontend_root = root / "src" / "client"
|
|
254
|
+
|
|
255
|
+
if backend_root.exists():
|
|
256
|
+
for path in sorted(backend_root.rglob("*.py")):
|
|
257
|
+
if not has_ignored_parent(path.relative_to(root)):
|
|
258
|
+
yield path, "backend"
|
|
259
|
+
|
|
260
|
+
if frontend_root.exists():
|
|
261
|
+
for path in sorted(frontend_root.rglob("*")):
|
|
262
|
+
if (
|
|
263
|
+
path.is_file()
|
|
264
|
+
and path.suffix in FRONTEND_EXTENSIONS
|
|
265
|
+
and not has_ignored_parent(path.relative_to(root))
|
|
266
|
+
):
|
|
267
|
+
yield path, "frontend"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def build_file_score(root: Path, path: Path, group: GroupName, include_tests: bool) -> FileScore:
|
|
271
|
+
if group == "backend":
|
|
272
|
+
effective_lines, warning = count_python_effective_lines(path)
|
|
273
|
+
else:
|
|
274
|
+
effective_lines, warning = count_web_effective_lines(path)
|
|
275
|
+
|
|
276
|
+
score, status = score_file(group, effective_lines)
|
|
277
|
+
test_file = is_test_file(path.relative_to(root))
|
|
278
|
+
|
|
279
|
+
return FileScore(
|
|
280
|
+
path=path,
|
|
281
|
+
relative_path=path.relative_to(root).as_posix(),
|
|
282
|
+
group=group,
|
|
283
|
+
effective_lines=effective_lines,
|
|
284
|
+
score=score,
|
|
285
|
+
status=status,
|
|
286
|
+
included_in_score=include_tests or not test_file,
|
|
287
|
+
is_test=test_file,
|
|
288
|
+
warning=warning,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def summarize_group(
|
|
293
|
+
name: GroupName | Literal["system"],
|
|
294
|
+
label: str,
|
|
295
|
+
files: list[FileScore],
|
|
296
|
+
) -> GroupScore:
|
|
297
|
+
included = [file for file in files if file.included_in_score]
|
|
298
|
+
score = mean(file.score for file in included) if included else None
|
|
299
|
+
return GroupScore(
|
|
300
|
+
name=name,
|
|
301
|
+
label=label,
|
|
302
|
+
score=score,
|
|
303
|
+
file_count=len(files),
|
|
304
|
+
included_count=len(included),
|
|
305
|
+
healthy_count=sum(1 for file in included if file.status == "healthy"),
|
|
306
|
+
warning_count=sum(1 for file in included if file.status == "warning"),
|
|
307
|
+
danger_count=sum(1 for file in included if file.status == "danger"),
|
|
308
|
+
total_effective_lines=sum(file.effective_lines for file in included),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def build_report(root: Path, include_tests: bool = False) -> QualityReport:
|
|
313
|
+
root = root.resolve()
|
|
314
|
+
files = [
|
|
315
|
+
build_file_score(root, path, group, include_tests)
|
|
316
|
+
for path, group in iter_source_files(root)
|
|
317
|
+
]
|
|
318
|
+
backend_files = [file for file in files if file.group == "backend"]
|
|
319
|
+
frontend_files = [file for file in files if file.group == "frontend"]
|
|
320
|
+
|
|
321
|
+
return QualityReport(
|
|
322
|
+
root=root,
|
|
323
|
+
generated_at=datetime.now(),
|
|
324
|
+
include_tests=include_tests,
|
|
325
|
+
files=files,
|
|
326
|
+
backend=summarize_group("backend", "后端", backend_files),
|
|
327
|
+
frontend=summarize_group("frontend", "前端", frontend_files),
|
|
328
|
+
system=summarize_group("system", "系统", files),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def format_score(score: float | int | None) -> str:
|
|
333
|
+
if score is None:
|
|
334
|
+
return "N/A"
|
|
335
|
+
if float(score).is_integer():
|
|
336
|
+
return str(int(score))
|
|
337
|
+
return f"{score:.1f}"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def score_class(score: float | None) -> str:
|
|
341
|
+
if score is None:
|
|
342
|
+
return "na"
|
|
343
|
+
if score >= 90:
|
|
344
|
+
return "healthy"
|
|
345
|
+
if score >= 60:
|
|
346
|
+
return "warning"
|
|
347
|
+
return "danger"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def group_to_dict(group: GroupScore) -> dict[str, object]:
|
|
351
|
+
return {
|
|
352
|
+
"score": None if group.score is None else round(group.score, 1),
|
|
353
|
+
"fileCount": group.file_count,
|
|
354
|
+
"includedCount": group.included_count,
|
|
355
|
+
"healthyCount": group.healthy_count,
|
|
356
|
+
"warningCount": group.warning_count,
|
|
357
|
+
"dangerCount": group.danger_count,
|
|
358
|
+
"totalEffectiveLines": group.total_effective_lines,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def render_metric_card(group: GroupScore) -> str:
|
|
363
|
+
score = format_score(group.score)
|
|
364
|
+
css_class = score_class(group.score)
|
|
365
|
+
score_value = "null" if group.score is None else f"{group.score:.1f}"
|
|
366
|
+
return f"""
|
|
367
|
+
<section class="score-card {css_class}">
|
|
368
|
+
<div class="score-card__meta">
|
|
369
|
+
<span>{html.escape(group.label)}</span>
|
|
370
|
+
<strong>{group.included_count}</strong>
|
|
371
|
+
</div>
|
|
372
|
+
<div class="score-card__score" data-score="{score_value}">{score}</div>
|
|
373
|
+
<div class="score-card__bar"><span style="--target:{0 if group.score is None else group.score}%"></span></div>
|
|
374
|
+
<dl class="score-card__details">
|
|
375
|
+
<div><dt>健康</dt><dd>{group.healthy_count}</dd></div>
|
|
376
|
+
<div><dt>警告</dt><dd>{group.warning_count}</dd></div>
|
|
377
|
+
<div><dt>危险</dt><dd>{group.danger_count}</dd></div>
|
|
378
|
+
<div><dt>有效行</dt><dd>{group.total_effective_lines}</dd></div>
|
|
379
|
+
</dl>
|
|
380
|
+
</section>
|
|
381
|
+
"""
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def render_file_rows(files: list[FileScore]) -> str:
|
|
385
|
+
rows = []
|
|
386
|
+
for file in sorted(files, key=lambda item: (-item.effective_lines, item.relative_path)):
|
|
387
|
+
rows.append(
|
|
388
|
+
f"""
|
|
389
|
+
<tr class="{file.status}{'' if file.included_in_score else ' excluded'}" data-status="{file.status}">
|
|
390
|
+
<td><code>{html.escape(file.relative_path)}</code></td>
|
|
391
|
+
<td>{'后端' if file.group == 'backend' else '前端'}</td>
|
|
392
|
+
<td>{file.effective_lines}</td>
|
|
393
|
+
<td>{format_score(file.score)}</td>
|
|
394
|
+
<td><span class="pill {file.status}">{status_label(file.status)}</span></td>
|
|
395
|
+
<td>{'计分' if file.included_in_score else '测试文件'}</td>
|
|
396
|
+
</tr>
|
|
397
|
+
"""
|
|
398
|
+
)
|
|
399
|
+
return "\n".join(rows)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def status_label(status: ScoreStatus) -> str:
|
|
403
|
+
return {"healthy": "健康", "warning": "警告", "danger": "危险"}[status]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def render_top_files(files: list[FileScore]) -> str:
|
|
407
|
+
included = [file for file in files if file.included_in_score]
|
|
408
|
+
ranked_files = sorted(included, key=lambda item: (-item.effective_lines, item.relative_path))
|
|
409
|
+
if not ranked_files:
|
|
410
|
+
return '<p class="empty">没有可计分文件。</p>'
|
|
411
|
+
|
|
412
|
+
items = []
|
|
413
|
+
max_lines = max(file.effective_lines for file in ranked_files) or 1
|
|
414
|
+
for file in ranked_files:
|
|
415
|
+
width = round(file.effective_lines / max_lines * 100, 2)
|
|
416
|
+
items.append(
|
|
417
|
+
f"""
|
|
418
|
+
<li data-group="{file.group}">
|
|
419
|
+
<div>
|
|
420
|
+
<strong>{html.escape(file.relative_path)}</strong>
|
|
421
|
+
<span>{file.effective_lines} 行 · {format_score(file.score)} 分</span>
|
|
422
|
+
</div>
|
|
423
|
+
<div class="rank-bar {file.status}"><span style="--target:{width}%"></span></div>
|
|
424
|
+
</li>
|
|
425
|
+
"""
|
|
426
|
+
)
|
|
427
|
+
return f"""
|
|
428
|
+
<ol class="top-list" data-top-list>{''.join(items)}</ol>
|
|
429
|
+
<p class="empty is-hidden" data-top-empty>没有匹配的文件。</p>
|
|
430
|
+
"""
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def render_warnings(files: list[FileScore]) -> str:
|
|
434
|
+
warnings = [file for file in files if file.warning]
|
|
435
|
+
if not warnings:
|
|
436
|
+
return ""
|
|
437
|
+
|
|
438
|
+
items = "".join(
|
|
439
|
+
f"<li><code>{html.escape(file.relative_path)}</code>: {html.escape(file.warning or '')}</li>"
|
|
440
|
+
for file in warnings
|
|
441
|
+
)
|
|
442
|
+
return f"""
|
|
443
|
+
<section class="panel warnings">
|
|
444
|
+
<h2>解析警告</h2>
|
|
445
|
+
<ul>{items}</ul>
|
|
446
|
+
</section>
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def render_html(report: QualityReport) -> str:
|
|
451
|
+
report_json = json.dumps(
|
|
452
|
+
{
|
|
453
|
+
"system": group_to_dict(report.system),
|
|
454
|
+
"backend": group_to_dict(report.backend),
|
|
455
|
+
"frontend": group_to_dict(report.frontend),
|
|
456
|
+
},
|
|
457
|
+
ensure_ascii=False,
|
|
458
|
+
)
|
|
459
|
+
generated_at = report.generated_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
460
|
+
test_policy = "参与评分" if report.include_tests else "默认排除"
|
|
461
|
+
|
|
462
|
+
return f"""<!doctype html>
|
|
463
|
+
<html lang="zh-CN">
|
|
464
|
+
<head>
|
|
465
|
+
<meta charset="utf-8">
|
|
466
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
467
|
+
<title>代码质量报告</title>
|
|
468
|
+
<style>
|
|
469
|
+
:root {{
|
|
470
|
+
color-scheme: light;
|
|
471
|
+
--bg: #f5f7fb;
|
|
472
|
+
--surface: #ffffff;
|
|
473
|
+
--surface-strong: #101828;
|
|
474
|
+
--muted: #667085;
|
|
475
|
+
--line: #d9e2ec;
|
|
476
|
+
--green: #169b62;
|
|
477
|
+
--green-soft: #e7f7ef;
|
|
478
|
+
--amber: #c97706;
|
|
479
|
+
--amber-soft: #fff4dc;
|
|
480
|
+
--red: #d33f49;
|
|
481
|
+
--red-soft: #ffeaec;
|
|
482
|
+
--blue: #276ef1;
|
|
483
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
484
|
+
}}
|
|
485
|
+
* {{ box-sizing: border-box; }}
|
|
486
|
+
body {{
|
|
487
|
+
margin: 0;
|
|
488
|
+
min-height: 100vh;
|
|
489
|
+
background:
|
|
490
|
+
linear-gradient(135deg, rgba(39, 110, 241, 0.08), transparent 28rem),
|
|
491
|
+
linear-gradient(315deg, rgba(22, 155, 98, 0.08), transparent 30rem),
|
|
492
|
+
var(--bg);
|
|
493
|
+
color: #172033;
|
|
494
|
+
}}
|
|
495
|
+
main {{
|
|
496
|
+
width: min(1440px, calc(100% - 48px));
|
|
497
|
+
margin: 0 auto;
|
|
498
|
+
padding: 32px 0 48px;
|
|
499
|
+
}}
|
|
500
|
+
header {{
|
|
501
|
+
display: grid;
|
|
502
|
+
grid-template-columns: 1fr auto;
|
|
503
|
+
gap: 24px;
|
|
504
|
+
align-items: end;
|
|
505
|
+
margin-bottom: 24px;
|
|
506
|
+
}}
|
|
507
|
+
h1, h2 {{ margin: 0; letter-spacing: 0; }}
|
|
508
|
+
h1 {{ font-size: 32px; line-height: 1.15; color: var(--surface-strong); }}
|
|
509
|
+
h2 {{ font-size: 18px; }}
|
|
510
|
+
.subtitle {{ margin: 10px 0 0; color: var(--muted); max-width: 820px; line-height: 1.7; }}
|
|
511
|
+
.meta {{
|
|
512
|
+
display: grid;
|
|
513
|
+
gap: 8px;
|
|
514
|
+
min-width: 260px;
|
|
515
|
+
padding: 14px 16px;
|
|
516
|
+
border: 1px solid var(--line);
|
|
517
|
+
border-radius: 8px;
|
|
518
|
+
background: rgba(255,255,255,0.76);
|
|
519
|
+
}}
|
|
520
|
+
.meta span {{ color: var(--muted); font-size: 13px; }}
|
|
521
|
+
.meta strong {{ font-size: 14px; color: #24304a; }}
|
|
522
|
+
.score-grid {{
|
|
523
|
+
display: grid;
|
|
524
|
+
grid-template-columns: 1.1fr 1fr 1fr;
|
|
525
|
+
gap: 16px;
|
|
526
|
+
margin-bottom: 16px;
|
|
527
|
+
}}
|
|
528
|
+
.score-card, .panel {{
|
|
529
|
+
border: 1px solid var(--line);
|
|
530
|
+
border-radius: 8px;
|
|
531
|
+
background: rgba(255,255,255,0.9);
|
|
532
|
+
box-shadow: 0 18px 40px rgba(16, 24, 40, 0.08);
|
|
533
|
+
}}
|
|
534
|
+
.score-card {{ padding: 20px; overflow: hidden; }}
|
|
535
|
+
.score-card__meta {{
|
|
536
|
+
display: flex;
|
|
537
|
+
justify-content: space-between;
|
|
538
|
+
gap: 16px;
|
|
539
|
+
color: var(--muted);
|
|
540
|
+
font-size: 14px;
|
|
541
|
+
}}
|
|
542
|
+
.score-card__meta strong {{ color: #24304a; }}
|
|
543
|
+
.score-card__score {{
|
|
544
|
+
margin-top: 12px;
|
|
545
|
+
font-size: 56px;
|
|
546
|
+
line-height: 1;
|
|
547
|
+
font-weight: 760;
|
|
548
|
+
color: var(--surface-strong);
|
|
549
|
+
}}
|
|
550
|
+
.score-card.healthy .score-card__score {{ color: var(--green); }}
|
|
551
|
+
.score-card.warning .score-card__score {{ color: var(--amber); }}
|
|
552
|
+
.score-card.danger .score-card__score {{ color: var(--red); }}
|
|
553
|
+
.score-card__bar, .rank-bar {{
|
|
554
|
+
height: 9px;
|
|
555
|
+
margin-top: 16px;
|
|
556
|
+
overflow: hidden;
|
|
557
|
+
border-radius: 999px;
|
|
558
|
+
background: #edf1f7;
|
|
559
|
+
}}
|
|
560
|
+
.score-card__bar span, .rank-bar span {{
|
|
561
|
+
display: block;
|
|
562
|
+
width: var(--target);
|
|
563
|
+
height: 100%;
|
|
564
|
+
transform-origin: left;
|
|
565
|
+
animation: grow 880ms cubic-bezier(.2,.8,.2,1) both;
|
|
566
|
+
}}
|
|
567
|
+
.score-card.healthy .score-card__bar span, .rank-bar.healthy span {{ background: var(--green); }}
|
|
568
|
+
.score-card.warning .score-card__bar span, .rank-bar.warning span {{ background: var(--amber); }}
|
|
569
|
+
.score-card.danger .score-card__bar span, .rank-bar.danger span {{ background: var(--red); }}
|
|
570
|
+
.score-card.na .score-card__bar span {{ background: var(--muted); }}
|
|
571
|
+
.score-card__details {{
|
|
572
|
+
display: grid;
|
|
573
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
574
|
+
gap: 10px;
|
|
575
|
+
margin: 18px 0 0;
|
|
576
|
+
}}
|
|
577
|
+
.score-card__details div {{
|
|
578
|
+
min-width: 0;
|
|
579
|
+
padding: 10px;
|
|
580
|
+
border-radius: 8px;
|
|
581
|
+
background: #f7f9fc;
|
|
582
|
+
}}
|
|
583
|
+
dt {{ color: var(--muted); font-size: 12px; }}
|
|
584
|
+
dd {{ margin: 3px 0 0; font-weight: 720; color: #24304a; }}
|
|
585
|
+
.content-grid {{
|
|
586
|
+
display: grid;
|
|
587
|
+
grid-template-columns: 0.92fr 1.08fr;
|
|
588
|
+
gap: 16px;
|
|
589
|
+
align-items: start;
|
|
590
|
+
}}
|
|
591
|
+
.panel {{ padding: 18px; }}
|
|
592
|
+
.panel__header {{
|
|
593
|
+
display: flex;
|
|
594
|
+
justify-content: space-between;
|
|
595
|
+
gap: 16px;
|
|
596
|
+
align-items: center;
|
|
597
|
+
margin-bottom: 14px;
|
|
598
|
+
}}
|
|
599
|
+
.panel h2 {{ margin-bottom: 0; }}
|
|
600
|
+
.segmented {{
|
|
601
|
+
display: inline-flex;
|
|
602
|
+
flex-wrap: wrap;
|
|
603
|
+
gap: 4px;
|
|
604
|
+
padding: 4px;
|
|
605
|
+
border: 1px solid #dfe7f0;
|
|
606
|
+
border-radius: 8px;
|
|
607
|
+
background: #f7f9fc;
|
|
608
|
+
}}
|
|
609
|
+
.segmented button {{
|
|
610
|
+
min-height: 30px;
|
|
611
|
+
border: 0;
|
|
612
|
+
border-radius: 6px;
|
|
613
|
+
padding: 5px 10px;
|
|
614
|
+
background: transparent;
|
|
615
|
+
color: var(--muted);
|
|
616
|
+
cursor: pointer;
|
|
617
|
+
font: inherit;
|
|
618
|
+
font-size: 12px;
|
|
619
|
+
font-weight: 720;
|
|
620
|
+
}}
|
|
621
|
+
.segmented button:hover {{ color: #24304a; background: #edf2f8; }}
|
|
622
|
+
.segmented button.active {{
|
|
623
|
+
color: #ffffff;
|
|
624
|
+
background: var(--surface-strong);
|
|
625
|
+
box-shadow: 0 6px 14px rgba(16, 24, 40, 0.16);
|
|
626
|
+
}}
|
|
627
|
+
.top-list {{
|
|
628
|
+
display: grid;
|
|
629
|
+
gap: 13px;
|
|
630
|
+
margin: 0;
|
|
631
|
+
padding: 0;
|
|
632
|
+
list-style: none;
|
|
633
|
+
}}
|
|
634
|
+
.top-list li {{
|
|
635
|
+
display: grid;
|
|
636
|
+
gap: 8px;
|
|
637
|
+
padding: 12px;
|
|
638
|
+
border: 1px solid #e8edf4;
|
|
639
|
+
border-radius: 8px;
|
|
640
|
+
background: #fbfcfe;
|
|
641
|
+
}}
|
|
642
|
+
.top-list div:first-child {{
|
|
643
|
+
display: grid;
|
|
644
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
645
|
+
gap: 12px;
|
|
646
|
+
align-items: center;
|
|
647
|
+
}}
|
|
648
|
+
.top-list strong {{
|
|
649
|
+
min-width: 0;
|
|
650
|
+
overflow-wrap: anywhere;
|
|
651
|
+
font-size: 13px;
|
|
652
|
+
}}
|
|
653
|
+
.top-list span {{ color: var(--muted); font-size: 12px; white-space: nowrap; }}
|
|
654
|
+
.table-wrap {{ overflow-x: auto; border: 1px solid var(--line); border-radius: 8px; }}
|
|
655
|
+
table {{ width: 100%; border-collapse: collapse; min-width: 860px; background: #fff; }}
|
|
656
|
+
th, td {{ padding: 11px 12px; border-bottom: 1px solid #edf1f7; text-align: left; font-size: 13px; }}
|
|
657
|
+
th {{ position: sticky; top: 0; background: #f7f9fc; color: #344054; font-weight: 720; }}
|
|
658
|
+
td:nth-child(3), td:nth-child(4) {{ font-variant-numeric: tabular-nums; }}
|
|
659
|
+
tr:last-child td {{ border-bottom: 0; }}
|
|
660
|
+
tr.warning {{ background: var(--amber-soft); }}
|
|
661
|
+
tr.danger {{ background: var(--red-soft); }}
|
|
662
|
+
tr.excluded {{ opacity: .58; }}
|
|
663
|
+
code {{
|
|
664
|
+
color: #18243a;
|
|
665
|
+
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
|
666
|
+
overflow-wrap: anywhere;
|
|
667
|
+
}}
|
|
668
|
+
.pill {{
|
|
669
|
+
display: inline-flex;
|
|
670
|
+
min-width: 44px;
|
|
671
|
+
justify-content: center;
|
|
672
|
+
border-radius: 999px;
|
|
673
|
+
padding: 3px 8px;
|
|
674
|
+
font-size: 12px;
|
|
675
|
+
font-weight: 700;
|
|
676
|
+
}}
|
|
677
|
+
.pill.healthy {{ background: var(--green-soft); color: var(--green); }}
|
|
678
|
+
.pill.warning {{ background: #ffe8b3; color: var(--amber); }}
|
|
679
|
+
.pill.danger {{ background: #ffd6da; color: var(--red); }}
|
|
680
|
+
.warnings {{ margin-top: 16px; }}
|
|
681
|
+
.warnings ul {{ margin: 0; padding-left: 18px; color: var(--muted); }}
|
|
682
|
+
.empty {{ color: var(--muted); margin: 0; }}
|
|
683
|
+
.is-hidden {{ display: none !important; }}
|
|
684
|
+
@keyframes grow {{
|
|
685
|
+
from {{ transform: scaleX(0); }}
|
|
686
|
+
to {{ transform: scaleX(1); }}
|
|
687
|
+
}}
|
|
688
|
+
@media (max-width: 960px) {{
|
|
689
|
+
main {{ width: min(100% - 28px, 1440px); padding-top: 22px; }}
|
|
690
|
+
header, .content-grid, .score-grid {{ grid-template-columns: 1fr; }}
|
|
691
|
+
.meta {{ min-width: 0; }}
|
|
692
|
+
.score-card__score {{ font-size: 46px; }}
|
|
693
|
+
.panel__header {{ align-items: flex-start; flex-direction: column; }}
|
|
694
|
+
}}
|
|
695
|
+
</style>
|
|
696
|
+
</head>
|
|
697
|
+
<body>
|
|
698
|
+
<main>
|
|
699
|
+
<header>
|
|
700
|
+
<div>
|
|
701
|
+
<h1>代码质量报告</h1>
|
|
702
|
+
<p class="subtitle">基于有效代码行数评分:后端 300 行以内满分,300 到 600 行线性降至 0 分;前端 600 行以内满分,600 到 1000 行线性降至 0 分。系统分数按所有参与评分文件平均。</p>
|
|
703
|
+
</div>
|
|
704
|
+
<div class="meta">
|
|
705
|
+
<span>生成时间</span><strong>{html.escape(generated_at)}</strong>
|
|
706
|
+
<span>项目根目录</span><strong>{html.escape(str(report.root))}</strong>
|
|
707
|
+
<span>测试文件</span><strong>{test_policy}</strong>
|
|
708
|
+
</div>
|
|
709
|
+
</header>
|
|
710
|
+
|
|
711
|
+
<section class="score-grid">
|
|
712
|
+
{render_metric_card(report.system)}
|
|
713
|
+
{render_metric_card(report.backend)}
|
|
714
|
+
{render_metric_card(report.frontend)}
|
|
715
|
+
</section>
|
|
716
|
+
|
|
717
|
+
<section class="content-grid">
|
|
718
|
+
<section class="panel">
|
|
719
|
+
<div class="panel__header">
|
|
720
|
+
<h2>最大文件排行</h2>
|
|
721
|
+
<div class="segmented" data-filter-group="top">
|
|
722
|
+
<button type="button" class="active" data-filter-value="all">全部</button>
|
|
723
|
+
<button type="button" data-filter-value="backend">后端</button>
|
|
724
|
+
<button type="button" data-filter-value="frontend">前端</button>
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
{render_top_files(report.files)}
|
|
728
|
+
</section>
|
|
729
|
+
<section class="panel">
|
|
730
|
+
<div class="panel__header">
|
|
731
|
+
<h2>文件明细</h2>
|
|
732
|
+
<div class="segmented" data-filter-group="details">
|
|
733
|
+
<button type="button" class="active" data-filter-value="all">全部</button>
|
|
734
|
+
<button type="button" data-filter-value="healthy">健康</button>
|
|
735
|
+
<button type="button" data-filter-value="warning">警告</button>
|
|
736
|
+
<button type="button" data-filter-value="danger">危险</button>
|
|
737
|
+
</div>
|
|
738
|
+
</div>
|
|
739
|
+
<div class="table-wrap">
|
|
740
|
+
<table>
|
|
741
|
+
<thead>
|
|
742
|
+
<tr>
|
|
743
|
+
<th>文件</th>
|
|
744
|
+
<th>分组</th>
|
|
745
|
+
<th>有效行</th>
|
|
746
|
+
<th>分数</th>
|
|
747
|
+
<th>状态</th>
|
|
748
|
+
<th>范围</th>
|
|
749
|
+
</tr>
|
|
750
|
+
</thead>
|
|
751
|
+
<tbody>{render_file_rows(report.files)}</tbody>
|
|
752
|
+
</table>
|
|
753
|
+
</div>
|
|
754
|
+
<p class="empty is-hidden" data-detail-empty>没有匹配的文件。</p>
|
|
755
|
+
</section>
|
|
756
|
+
</section>
|
|
757
|
+
|
|
758
|
+
{render_warnings(report.files)}
|
|
759
|
+
</main>
|
|
760
|
+
<script type="application/json" id="quality-data">{html.escape(report_json)}</script>
|
|
761
|
+
<script>
|
|
762
|
+
const setActiveButton = (button) => {{
|
|
763
|
+
const group = button.closest('[data-filter-group]');
|
|
764
|
+
for (const item of group.querySelectorAll('button')) item.classList.remove('active');
|
|
765
|
+
button.classList.add('active');
|
|
766
|
+
}};
|
|
767
|
+
|
|
768
|
+
const applyTopFilter = (value) => {{
|
|
769
|
+
const list = document.querySelector('[data-top-list]');
|
|
770
|
+
const empty = document.querySelector('[data-top-empty]');
|
|
771
|
+
if (!list) return;
|
|
772
|
+
let shown = 0;
|
|
773
|
+
for (const item of list.querySelectorAll('li')) {{
|
|
774
|
+
const matchesGroup = value === 'all' || item.dataset.group === value;
|
|
775
|
+
const visible = matchesGroup && shown < 8;
|
|
776
|
+
item.classList.toggle('is-hidden', !visible);
|
|
777
|
+
if (visible) shown += 1;
|
|
778
|
+
}}
|
|
779
|
+
if (empty) empty.classList.toggle('is-hidden', shown > 0);
|
|
780
|
+
}};
|
|
781
|
+
|
|
782
|
+
const applyDetailFilter = (value) => {{
|
|
783
|
+
let shown = 0;
|
|
784
|
+
for (const row of document.querySelectorAll('tbody tr[data-status]')) {{
|
|
785
|
+
const visible = value === 'all' || row.dataset.status === value;
|
|
786
|
+
row.classList.toggle('is-hidden', !visible);
|
|
787
|
+
if (visible) shown += 1;
|
|
788
|
+
}}
|
|
789
|
+
const empty = document.querySelector('[data-detail-empty]');
|
|
790
|
+
if (empty) empty.classList.toggle('is-hidden', shown > 0);
|
|
791
|
+
}};
|
|
792
|
+
|
|
793
|
+
for (const button of document.querySelectorAll('[data-filter-group="top"] button')) {{
|
|
794
|
+
button.addEventListener('click', () => {{
|
|
795
|
+
setActiveButton(button);
|
|
796
|
+
applyTopFilter(button.dataset.filterValue);
|
|
797
|
+
}});
|
|
798
|
+
}}
|
|
799
|
+
|
|
800
|
+
for (const button of document.querySelectorAll('[data-filter-group="details"] button')) {{
|
|
801
|
+
button.addEventListener('click', () => {{
|
|
802
|
+
setActiveButton(button);
|
|
803
|
+
applyDetailFilter(button.dataset.filterValue);
|
|
804
|
+
}});
|
|
805
|
+
}}
|
|
806
|
+
|
|
807
|
+
applyTopFilter('all');
|
|
808
|
+
applyDetailFilter('all');
|
|
809
|
+
|
|
810
|
+
for (const el of document.querySelectorAll('[data-score]')) {{
|
|
811
|
+
const raw = el.dataset.score;
|
|
812
|
+
if (raw === 'null') continue;
|
|
813
|
+
const target = Number(raw);
|
|
814
|
+
const duration = 760;
|
|
815
|
+
const startedAt = performance.now();
|
|
816
|
+
const tick = (now) => {{
|
|
817
|
+
const progress = Math.min((now - startedAt) / duration, 1);
|
|
818
|
+
const eased = 1 - Math.pow(1 - progress, 3);
|
|
819
|
+
const value = target * eased;
|
|
820
|
+
el.textContent = Number.isInteger(target) ? String(Math.round(value)) : value.toFixed(1);
|
|
821
|
+
if (progress < 1) requestAnimationFrame(tick);
|
|
822
|
+
else el.textContent = Number.isInteger(target) ? String(target) : target.toFixed(1);
|
|
823
|
+
}};
|
|
824
|
+
requestAnimationFrame(tick);
|
|
825
|
+
}}
|
|
826
|
+
</script>
|
|
827
|
+
</body>
|
|
828
|
+
</html>
|
|
829
|
+
"""
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def write_report(report: QualityReport, output_path: Path) -> Path:
|
|
833
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
834
|
+
output_path.write_text(render_html(report), encoding="utf-8")
|
|
835
|
+
return output_path
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jeseki-code-quality"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Generate an offline HTML code quality report."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"typer>=0.26.8",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
code-quality = "code_quality.cli:app"
|
|
13
|
+
cq = "code_quality.cli:app"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["code_quality"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.13"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "annotated-doc"
|
|
7
|
+
version = "0.0.4"
|
|
8
|
+
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
|
9
|
+
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://mirrors.aliyun.com/pypi/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "colorama"
|
|
16
|
+
version = "0.4.6"
|
|
17
|
+
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
|
18
|
+
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" }
|
|
19
|
+
wheels = [
|
|
20
|
+
{ url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[[package]]
|
|
24
|
+
name = "jeseki-code-quality"
|
|
25
|
+
version = "0.1.0"
|
|
26
|
+
source = { editable = "." }
|
|
27
|
+
dependencies = [
|
|
28
|
+
{ name = "typer" },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[package.metadata]
|
|
32
|
+
requires-dist = [{ name = "typer", specifier = ">=0.26.8" }]
|
|
33
|
+
|
|
34
|
+
[[package]]
|
|
35
|
+
name = "markdown-it-py"
|
|
36
|
+
version = "4.2.0"
|
|
37
|
+
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
|
38
|
+
dependencies = [
|
|
39
|
+
{ name = "mdurl" },
|
|
40
|
+
]
|
|
41
|
+
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49" }
|
|
42
|
+
wheels = [
|
|
43
|
+
{ url = "https://mirrors.aliyun.com/pypi/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a" },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[[package]]
|
|
47
|
+
name = "mdurl"
|
|
48
|
+
version = "0.1.2"
|
|
49
|
+
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
|
50
|
+
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" }
|
|
51
|
+
wheels = [
|
|
52
|
+
{ url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[[package]]
|
|
56
|
+
name = "pygments"
|
|
57
|
+
version = "2.20.0"
|
|
58
|
+
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
|
59
|
+
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" }
|
|
60
|
+
wheels = [
|
|
61
|
+
{ url = "https://mirrors.aliyun.com/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" },
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[[package]]
|
|
65
|
+
name = "rich"
|
|
66
|
+
version = "15.0.0"
|
|
67
|
+
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
|
68
|
+
dependencies = [
|
|
69
|
+
{ name = "markdown-it-py" },
|
|
70
|
+
{ name = "pygments" },
|
|
71
|
+
]
|
|
72
|
+
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" }
|
|
73
|
+
wheels = [
|
|
74
|
+
{ url = "https://mirrors.aliyun.com/pypi/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb" },
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[[package]]
|
|
78
|
+
name = "shellingham"
|
|
79
|
+
version = "1.5.4"
|
|
80
|
+
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
|
81
|
+
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" }
|
|
82
|
+
wheels = [
|
|
83
|
+
{ url = "https://mirrors.aliyun.com/pypi/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" },
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[[package]]
|
|
87
|
+
name = "typer"
|
|
88
|
+
version = "0.26.8"
|
|
89
|
+
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
|
|
90
|
+
dependencies = [
|
|
91
|
+
{ name = "annotated-doc" },
|
|
92
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
93
|
+
{ name = "rich" },
|
|
94
|
+
{ name = "shellingham" },
|
|
95
|
+
]
|
|
96
|
+
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e" }
|
|
97
|
+
wheels = [
|
|
98
|
+
{ url = "https://mirrors.aliyun.com/pypi/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c" },
|
|
99
|
+
]
|