normik 0.1.0__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.
- nk/__init__.py +3 -0
- nk/cli.py +331 -0
- nk/core/__init__.py +1 -0
- nk/core/baseline.py +94 -0
- nk/core/diagnostics.py +44 -0
- nk/core/document.py +219 -0
- nk/core/finding.py +97 -0
- nk/core/fixer.py +135 -0
- nk/core/numbering.py +82 -0
- nk/core/position.py +50 -0
- nk/core/profile.py +197 -0
- nk/core/registry.py +91 -0
- nk/core/rule.py +225 -0
- nk/core/runner.py +197 -0
- nk/core/suppressions.py +51 -0
- nk/parse/__init__.py +1 -0
- nk/parse/issues.py +60 -0
- nk/parse/numbering.py +194 -0
- nk/parse/structure.py +336 -0
- nk/parse/suppressions.py +48 -0
- nk/parse/tex.py +236 -0
- nk/profiles/__init__.py +1 -0
- nk/profiles/base.toml +17 -0
- nk/py.typed +0 -0
- nk/report/__init__.py +1 -0
- nk/report/agent.py +78 -0
- nk/report/common.py +57 -0
- nk/report/examples.py +69 -0
- nk/report/human.py +81 -0
- nk/report/json.py +55 -0
- nk/report/rules_docs.py +213 -0
- nk/rules/__init__.py +14 -0
- nk/rules/_shared.py +324 -0
- nk/rules/_text.py +46 -0
- nk/rules/elements/__init__.py +1 -0
- nk/rules/elements/appendix_figure_numbering.py +46 -0
- nk/rules/elements/appendix_formula_numbering.py +46 -0
- nk/rules/elements/appendix_table_numbering.py +46 -0
- nk/rules/elements/bibliography_bibtex.py +48 -0
- nk/rules/elements/bibliography_cite_unresolved.py +66 -0
- nk/rules/elements/bibliography_order.py +69 -0
- nk/rules/elements/bibliography_uncited.py +47 -0
- nk/rules/elements/figure_caption_capital.py +50 -0
- nk/rules/elements/figure_caption_dot.py +44 -0
- nk/rules/elements/figure_caption_hyphenation.py +50 -0
- nk/rules/elements/figure_caption_manual_number.py +55 -0
- nk/rules/elements/figure_caption_position.py +45 -0
- nk/rules/elements/figure_reference.py +54 -0
- nk/rules/elements/figure_reference_word.py +44 -0
- nk/rules/elements/footnote_space.py +53 -0
- nk/rules/elements/formula_blank_lines.py +62 -0
- nk/rules/elements/formula_reference.py +51 -0
- nk/rules/elements/formula_reference_format.py +59 -0
- nk/rules/elements/formula_where_colon.py +46 -0
- nk/rules/elements/note_capital.py +50 -0
- nk/rules/elements/note_dash.py +68 -0
- nk/rules/elements/numbering_scheme_mixed.py +48 -0
- nk/rules/elements/table_caption_capital.py +49 -0
- nk/rules/elements/table_caption_dot.py +42 -0
- nk/rules/elements/table_caption_manual_number.py +53 -0
- nk/rules/elements/table_caption_position.py +43 -0
- nk/rules/elements/table_reference.py +52 -0
- nk/rules/elements/table_reference_word.py +41 -0
- nk/rules/layout/__init__.py +1 -0
- nk/rules/layout/enumeration_letters.py +55 -0
- nk/rules/layout/heading_depth.py +49 -0
- nk/rules/layout/heading_dot.py +47 -0
- nk/rules/layout/heading_empty.py +47 -0
- nk/rules/layout/heading_hyphenation.py +51 -0
- nk/rules/layout/manual_section_number.py +71 -0
- nk/rules/layout/structural_heading_case.py +52 -0
- nk/rules/layout/structural_heading_numbered.py +49 -0
- nk/rules/structure/__init__.py +1 -0
- nk/rules/structure/abbreviations_opening.py +55 -0
- nk/rules/structure/abstract_volume_info.py +53 -0
- nk/rules/structure/abstract_volume_inline.py +91 -0
- nk/rules/structure/appendix_internal_numbering.py +54 -0
- nk/rules/structure/appendix_letter.py +52 -0
- nk/rules/structure/appendix_page_break.py +59 -0
- nk/rules/structure/appendix_reference.py +67 -0
- nk/rules/structure/appendix_sequence.py +66 -0
- nk/rules/structure/elements_order.py +56 -0
- nk/rules/structure/keywords_count.py +57 -0
- nk/rules/structure/keywords_final_dot.py +38 -0
- nk/rules/structure/keywords_uppercase.py +43 -0
- nk/rules/structure/required_elements.py +66 -0
- nk/rules/structure/terms_opening.py +54 -0
- nk/rules/typography/__init__.py +6 -0
- nk/rules/typography/abbreviations_dash.py +55 -0
- nk/rules/typography/initials_nbsp.py +81 -0
- nk/rules/typography/particle_nbsp.py +56 -0
- nk/rules/typography/preposition_nbsp.py +79 -0
- nk/rules/typography/reference_nbsp.py +53 -0
- nk/rules/typography/terms_dash.py +55 -0
- nk/rules/typography/text_dash.py +83 -0
- nk/rules/typography/text_quotes.py +64 -0
- nk/rules/typography/unit_nbsp.py +69 -0
- normik-0.1.0.dist-info/METADATA +140 -0
- normik-0.1.0.dist-info/RECORD +102 -0
- normik-0.1.0.dist-info/WHEEL +4 -0
- normik-0.1.0.dist-info/entry_points.txt +2 -0
- normik-0.1.0.dist-info/licenses/LICENSE +21 -0
nk/__init__.py
ADDED
nk/cli.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Команды CLI. Вся логика — в ядре; здесь только разбор аргументов и вывод."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from nk import __version__
|
|
13
|
+
from nk.core.baseline import Baseline, BaselineError
|
|
14
|
+
from nk.core.diagnostics import INTERNAL
|
|
15
|
+
from nk.core.finding import Severity
|
|
16
|
+
from nk.core.fixer import diff, plan, write
|
|
17
|
+
from nk.core.profile import Profile, ProfileError, load_profile
|
|
18
|
+
from nk.core.registry import load_rules, partition_ids, select_rules, validate_profile
|
|
19
|
+
from nk.core.rule import RuleImpl, UnknownRuleError
|
|
20
|
+
from nk.core.runner import RunResult, run
|
|
21
|
+
from nk.parse.tex import parse, parse_findings
|
|
22
|
+
from nk.report import agent, examples, human, rules_docs
|
|
23
|
+
from nk.report import json as json_report
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="nk",
|
|
27
|
+
help="Линтер оформления отчёта о НИР по ГОСТ 7.32-2017 для исходников LaTeX.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
add_completion=False,
|
|
30
|
+
)
|
|
31
|
+
rules_app = typer.Typer(name="rules", help="Сведения о правилах.", no_args_is_help=True)
|
|
32
|
+
profile_app = typer.Typer(name="profile", help="Сведения о профилях.", no_args_is_help=True)
|
|
33
|
+
app.add_typer(rules_app)
|
|
34
|
+
app.add_typer(profile_app)
|
|
35
|
+
|
|
36
|
+
console = Console()
|
|
37
|
+
err_console = Console(stderr=True)
|
|
38
|
+
|
|
39
|
+
EXIT_OK = 0
|
|
40
|
+
EXIT_FOUND_ERRORS = 1
|
|
41
|
+
EXIT_INTERNAL_ERROR = 2
|
|
42
|
+
|
|
43
|
+
#: Сколько раз перепроверять исходники, применяя найденные правки.
|
|
44
|
+
FIX_PASSES = 5
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OutputFormat(StrEnum):
|
|
48
|
+
HUMAN = "human"
|
|
49
|
+
AGENT = "agent"
|
|
50
|
+
JSON = "json"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.callback(invoke_without_command=True)
|
|
54
|
+
def main(
|
|
55
|
+
version: bool = typer.Option(False, "--version", help="Показать версию и выйти."),
|
|
56
|
+
) -> None:
|
|
57
|
+
if version:
|
|
58
|
+
console.print(f"nk {__version__}")
|
|
59
|
+
raise typer.Exit()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@rules_app.command("list")
|
|
63
|
+
def rules_list() -> None:
|
|
64
|
+
"""Перечень правил: идентификатор, пункт стандарта, уровень, название."""
|
|
65
|
+
registry = load_rules()
|
|
66
|
+
if not len(registry):
|
|
67
|
+
console.print("Правил пока нет.")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
table = Table(box=None, pad_edge=False)
|
|
71
|
+
table.add_column("ID")
|
|
72
|
+
table.add_column("Пункт")
|
|
73
|
+
table.add_column("Уровень")
|
|
74
|
+
table.add_column("Название")
|
|
75
|
+
for impl in registry:
|
|
76
|
+
table.add_row(impl.id, impl.clause, impl.severity.value, impl.title)
|
|
77
|
+
console.print(table)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@rules_app.command("show")
|
|
81
|
+
def rules_show(rule_id: str = typer.Argument(..., help="Идентификатор правила.")) -> None:
|
|
82
|
+
"""Подробности по правилу."""
|
|
83
|
+
registry = load_rules()
|
|
84
|
+
try:
|
|
85
|
+
impl = registry.get(rule_id)
|
|
86
|
+
except UnknownRuleError:
|
|
87
|
+
err_console.print(f"Неизвестное правило: {rule_id}")
|
|
88
|
+
raise typer.Exit(EXIT_INTERNAL_ERROR) from None
|
|
89
|
+
|
|
90
|
+
console.print(f"[bold]{impl.id}[/bold] — {impl.title}")
|
|
91
|
+
console.print(f"Пункт ГОСТ 7.32-2017: {impl.clause}")
|
|
92
|
+
console.print(f"Уровень по умолчанию: {impl.severity.value}")
|
|
93
|
+
console.print(f"Объявлено в: {impl.module}")
|
|
94
|
+
if impl.description:
|
|
95
|
+
console.print("")
|
|
96
|
+
console.print(impl.description)
|
|
97
|
+
console.print("")
|
|
98
|
+
if impl.default_params:
|
|
99
|
+
console.print("Параметры по умолчанию:")
|
|
100
|
+
for name, value in sorted(impl.default_params.items()):
|
|
101
|
+
console.print(f" {name} = {value!r}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@profile_app.command("show")
|
|
105
|
+
def profile_show(
|
|
106
|
+
profile_source: str = typer.Option(
|
|
107
|
+
None, "--profile", "-p", help="Имя встроенного профиля либо путь к TOML."
|
|
108
|
+
),
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Итоговый набор правил после применения профиля."""
|
|
111
|
+
registry = load_rules()
|
|
112
|
+
try:
|
|
113
|
+
profile = load_profile(profile_source)
|
|
114
|
+
validate_profile(profile, registry)
|
|
115
|
+
except ProfileError as error:
|
|
116
|
+
err_console.print(f"Профиль: {error}")
|
|
117
|
+
raise typer.Exit(EXIT_INTERNAL_ERROR) from None
|
|
118
|
+
|
|
119
|
+
profile = profile.resolve(registry.default_params())
|
|
120
|
+
active = {impl.id for impl in select_rules(registry, profile=profile)}
|
|
121
|
+
|
|
122
|
+
console.print(f"Профиль: [bold]{profile.name}[/bold]")
|
|
123
|
+
console.print(f"Правил включено: {len(active)} из {len(registry)}")
|
|
124
|
+
if not len(registry):
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
table = Table(box=None, pad_edge=False)
|
|
128
|
+
table.add_column("ID")
|
|
129
|
+
table.add_column("Уровень")
|
|
130
|
+
table.add_column("Состояние")
|
|
131
|
+
table.add_column("Параметры")
|
|
132
|
+
for impl in registry:
|
|
133
|
+
severity = profile.severity_for(impl.id, impl.severity)
|
|
134
|
+
changed = "" if severity is impl.severity else f" (было {impl.severity.value})"
|
|
135
|
+
params = profile.params_for(impl.id)
|
|
136
|
+
table.add_row(
|
|
137
|
+
impl.id,
|
|
138
|
+
f"{severity.value}{changed}",
|
|
139
|
+
"включено" if impl.id in active else "отключено",
|
|
140
|
+
", ".join(f"{k}={v!r}" for k, v in sorted(params.items())),
|
|
141
|
+
)
|
|
142
|
+
console.print(table)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command("check")
|
|
146
|
+
def check(
|
|
147
|
+
paths: list[Path] = typer.Argument(..., help="Файлы или каталоги с исходниками .tex."),
|
|
148
|
+
profile_source: str = typer.Option(
|
|
149
|
+
None, "--profile", "-p", help="Имя встроенного профиля либо путь к TOML."
|
|
150
|
+
),
|
|
151
|
+
output_format: OutputFormat = typer.Option(
|
|
152
|
+
OutputFormat.HUMAN, "--format", "-f", help="Формат вывода."
|
|
153
|
+
),
|
|
154
|
+
select: str = typer.Option(
|
|
155
|
+
None, "--select", help="Запустить только эти правила, через запятую."
|
|
156
|
+
),
|
|
157
|
+
ignore: str = typer.Option(None, "--ignore", help="Исключить эти правила, через запятую."),
|
|
158
|
+
severity: Severity = typer.Option(
|
|
159
|
+
Severity.INFO, "--severity", help="Не показывать находки ниже уровня."
|
|
160
|
+
),
|
|
161
|
+
limit: int = typer.Option(
|
|
162
|
+
agent.DEFAULT_LIMIT, "--limit", help="Предел числа находок в выводе; 0 — без предела."
|
|
163
|
+
),
|
|
164
|
+
fix: bool = typer.Option(False, "--fix", help="Применить правки к исходникам."),
|
|
165
|
+
show_diff: bool = typer.Option(
|
|
166
|
+
False, "--diff", help="Показать правки как diff, ничего не записывая."
|
|
167
|
+
),
|
|
168
|
+
baseline_path: Path = typer.Option(
|
|
169
|
+
None, "--baseline", help="Снимок известных нарушений: показывать только новые."
|
|
170
|
+
),
|
|
171
|
+
write_baseline: Path = typer.Option(
|
|
172
|
+
None, "--write-baseline", help="Записать снимок текущих находок и выйти."
|
|
173
|
+
),
|
|
174
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Только код возврата."),
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Проверить исходники отчёта на соответствие ГОСТ 7.32-2017."""
|
|
177
|
+
missing = [path for path in paths if not path.exists()]
|
|
178
|
+
if missing:
|
|
179
|
+
err_console.print("Не найдено: " + ", ".join(str(path) for path in missing))
|
|
180
|
+
raise typer.Exit(EXIT_INTERNAL_ERROR)
|
|
181
|
+
|
|
182
|
+
registry = load_rules()
|
|
183
|
+
try:
|
|
184
|
+
profile = load_profile(profile_source)
|
|
185
|
+
validate_profile(profile, registry)
|
|
186
|
+
selected_rules, _ = partition_ids(_split(select))
|
|
187
|
+
ignored_rules, ignored_internal = partition_ids(_split(ignore))
|
|
188
|
+
rules = select_rules(
|
|
189
|
+
registry,
|
|
190
|
+
profile=profile,
|
|
191
|
+
select=selected_rules,
|
|
192
|
+
ignore=ignored_rules,
|
|
193
|
+
)
|
|
194
|
+
baseline = Baseline.load(baseline_path) if baseline_path is not None else None
|
|
195
|
+
except (ProfileError, UnknownRuleError, BaselineError) as error:
|
|
196
|
+
err_console.print(str(error))
|
|
197
|
+
raise typer.Exit(EXIT_INTERNAL_ERROR) from None
|
|
198
|
+
|
|
199
|
+
check_run = _Check(
|
|
200
|
+
paths=paths,
|
|
201
|
+
rules=rules,
|
|
202
|
+
profile=profile.resolve(registry.default_params()),
|
|
203
|
+
baseline=baseline,
|
|
204
|
+
ignored=ignored_internal | (profile.disabled & frozenset(INTERNAL)),
|
|
205
|
+
known_ids=frozenset(impl.id for impl in registry) | frozenset(INTERNAL),
|
|
206
|
+
# Снимок фиксируется по всем находкам: иначе его содержимое зависело бы
|
|
207
|
+
# от ключа --severity, с которым его записали.
|
|
208
|
+
threshold=Severity.INFO if write_baseline is not None else severity,
|
|
209
|
+
)
|
|
210
|
+
result = check_run()
|
|
211
|
+
|
|
212
|
+
if write_baseline is not None:
|
|
213
|
+
_write_baseline(write_baseline, result)
|
|
214
|
+
raise typer.Exit(EXIT_OK)
|
|
215
|
+
|
|
216
|
+
if show_diff:
|
|
217
|
+
sys.stdout.write(_diff(check_run, result))
|
|
218
|
+
raise typer.Exit(EXIT_OK)
|
|
219
|
+
|
|
220
|
+
if fix:
|
|
221
|
+
result = _apply_fixes(check_run, result)
|
|
222
|
+
|
|
223
|
+
if not quiet:
|
|
224
|
+
_report(result, output_format, limit)
|
|
225
|
+
raise typer.Exit(EXIT_FOUND_ERRORS if result.has_errors else EXIT_OK)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True, slots=True)
|
|
229
|
+
class _Check:
|
|
230
|
+
"""Один прогон проверки. Ключ ``--fix`` повторяет его после каждой правки."""
|
|
231
|
+
|
|
232
|
+
paths: list[Path]
|
|
233
|
+
rules: tuple[RuleImpl, ...]
|
|
234
|
+
profile: Profile
|
|
235
|
+
baseline: Baseline | None
|
|
236
|
+
ignored: frozenset[str]
|
|
237
|
+
known_ids: frozenset[str]
|
|
238
|
+
threshold: Severity
|
|
239
|
+
|
|
240
|
+
def __call__(self, overlay: dict[Path, str] | None = None) -> RunResult:
|
|
241
|
+
parsed = parse(self.paths, self.profile, overlay)
|
|
242
|
+
return run(
|
|
243
|
+
parsed.document,
|
|
244
|
+
self.rules,
|
|
245
|
+
extra_findings=parse_findings(parsed),
|
|
246
|
+
threshold=self.threshold,
|
|
247
|
+
suppressions=parsed.suppressions,
|
|
248
|
+
baseline=self.baseline,
|
|
249
|
+
ignored=self.ignored,
|
|
250
|
+
known_ids=self.known_ids,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _diff(check_run: _Check, result: RunResult) -> str:
|
|
255
|
+
"""Показать всё, что сделал бы ``--fix``, ничего не записывая."""
|
|
256
|
+
overlay: dict[Path, str] = {}
|
|
257
|
+
for _ in range(FIX_PASSES):
|
|
258
|
+
prepared = plan(result.findings, overlay)
|
|
259
|
+
if not prepared.applied:
|
|
260
|
+
break
|
|
261
|
+
overlay.update({edit.path: edit.text for edit in prepared.edits})
|
|
262
|
+
result = check_run(overlay)
|
|
263
|
+
return diff(overlay)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _apply_fixes(check_run: _Check, result: RunResult) -> RunResult:
|
|
267
|
+
"""Применять правки, пока они находятся, и перепроверять исходники.
|
|
268
|
+
|
|
269
|
+
Проходов несколько: пересекающиеся правки в один проход не применяются,
|
|
270
|
+
а исправленное место может открыть следующее нарушение.
|
|
271
|
+
"""
|
|
272
|
+
applied = 0
|
|
273
|
+
reported: set[Path] = set()
|
|
274
|
+
for _ in range(FIX_PASSES):
|
|
275
|
+
prepared = plan(result.findings)
|
|
276
|
+
for path in prepared.skipped:
|
|
277
|
+
if path not in reported:
|
|
278
|
+
reported.add(path)
|
|
279
|
+
err_console.print(f"Не удалось прочитать как UTF-8, пропущен: {path}")
|
|
280
|
+
if not prepared.applied:
|
|
281
|
+
break
|
|
282
|
+
applied += write(prepared)
|
|
283
|
+
result = check_run()
|
|
284
|
+
|
|
285
|
+
if applied:
|
|
286
|
+
console.print(f"Исправлено находок: {applied}.")
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _write_baseline(path: Path, result: RunResult) -> None:
|
|
291
|
+
snapshot = Baseline.of(result.findings)
|
|
292
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
path.write_text(snapshot.dumps(__version__), encoding="utf-8")
|
|
294
|
+
console.print(f"Записано находок в снимок: {len(result.findings)} → {path}")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _report(result: RunResult, output_format: OutputFormat, limit: int) -> None:
|
|
298
|
+
# agent и json пишутся в stdout напрямую: Rich переносил бы длинные строки,
|
|
299
|
+
# а оба формата копируются и разбираются как есть.
|
|
300
|
+
if output_format is OutputFormat.JSON:
|
|
301
|
+
sys.stdout.write(json_report.render(result))
|
|
302
|
+
elif output_format is OutputFormat.AGENT:
|
|
303
|
+
command = " ".join(["nk", *sys.argv[1:]])
|
|
304
|
+
sys.stdout.write(agent.render(result, command=command, limit=limit))
|
|
305
|
+
else:
|
|
306
|
+
human.render(result, console)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _split(value: str | None) -> list[str] | None:
|
|
310
|
+
if value is None:
|
|
311
|
+
return None
|
|
312
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@rules_app.command("docs")
|
|
316
|
+
def rules_docs_command(
|
|
317
|
+
output: Path = typer.Option(
|
|
318
|
+
Path("docs/rules"), "--output", "-o", help="Каталог, куда записать страницы правил."
|
|
319
|
+
),
|
|
320
|
+
fixtures: Path = typer.Option(
|
|
321
|
+
examples.DEFAULT_FIXTURES_ROOT,
|
|
322
|
+
"--fixtures",
|
|
323
|
+
help="Каталог фикстур, из которых берутся примеры.",
|
|
324
|
+
),
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Сгенерировать страницы документации по правилам."""
|
|
327
|
+
registry = load_rules()
|
|
328
|
+
written, removed = rules_docs.write_pages(registry, output, fixtures_root=fixtures)
|
|
329
|
+
console.print(f"Записано страниц: {written} → {output}")
|
|
330
|
+
if removed:
|
|
331
|
+
console.print(f"Удалено устаревших: {removed}")
|
nk/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Ядро: контракты находки, правила, документа и профиля."""
|
nk/core/baseline.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Снимок известных нарушений.
|
|
2
|
+
|
|
3
|
+
Линтер, включённый на готовой работе, выдаёт сотни находок сразу. Снимок фиксирует
|
|
4
|
+
текущее состояние, и дальше показываются только новые нарушения: работа чинится
|
|
5
|
+
постепенно, а не «когда-нибудь потом».
|
|
6
|
+
|
|
7
|
+
Находка опознаётся не номером строки, а содержимым строки нарушения: отчёт растёт
|
|
8
|
+
весь семестр, и вставка абзаца выше не должна воскрешать все находки ниже. Если же
|
|
9
|
+
саму строку правили — находка всплывёт снова, и это правильно.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from hashlib import sha256
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from nk.core.finding import Finding
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = "1.0"
|
|
22
|
+
TOOL_NAME = "nk"
|
|
23
|
+
FINGERPRINT_LENGTH = 16
|
|
24
|
+
|
|
25
|
+
#: Путь, ключ и отпечаток строки нарушения.
|
|
26
|
+
Key = tuple[str, str, str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BaselineError(ValueError):
|
|
30
|
+
"""Снимок не читается или испорчен."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Baseline:
|
|
35
|
+
counts: dict[Key, int] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def of(cls, findings: tuple[Finding, ...]) -> "Baseline":
|
|
39
|
+
return cls(dict(Counter(fingerprint(finding) for finding in findings)))
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def load(cls, path: Path) -> "Baseline":
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
except OSError as error:
|
|
46
|
+
raise BaselineError(f"не удалось прочитать снимок {path}: {error}") from error
|
|
47
|
+
except json.JSONDecodeError as error:
|
|
48
|
+
raise BaselineError(f"снимок {path} испорчен: {error}") from error
|
|
49
|
+
|
|
50
|
+
version = data.get("schema_version")
|
|
51
|
+
if version != SCHEMA_VERSION:
|
|
52
|
+
raise BaselineError(
|
|
53
|
+
f"снимок {path} версии {version!r}, поддерживается {SCHEMA_VERSION!r}"
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
counts = {
|
|
57
|
+
(entry["path"], entry["rule_id"], entry["fingerprint"]): int(entry["count"])
|
|
58
|
+
for entry in data["entries"]
|
|
59
|
+
}
|
|
60
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
61
|
+
raise BaselineError(f"снимок {path}: неожиданная структура записи ({error})") from error
|
|
62
|
+
return cls(counts)
|
|
63
|
+
|
|
64
|
+
def dumps(self, version: str) -> str:
|
|
65
|
+
entries = [
|
|
66
|
+
{"path": path, "rule_id": rule_id, "fingerprint": digest, "count": count}
|
|
67
|
+
for (path, rule_id, digest), count in sorted(self.counts.items())
|
|
68
|
+
]
|
|
69
|
+
payload: dict[str, Any] = {
|
|
70
|
+
"schema_version": SCHEMA_VERSION,
|
|
71
|
+
"tool": {"name": TOOL_NAME, "version": version},
|
|
72
|
+
"entries": entries,
|
|
73
|
+
}
|
|
74
|
+
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
|
75
|
+
|
|
76
|
+
def filter(self, findings: tuple[Finding, ...]) -> tuple[tuple[Finding, ...], int]:
|
|
77
|
+
"""Отсеять находки, уже учтённые снимком."""
|
|
78
|
+
remaining = dict(self.counts)
|
|
79
|
+
kept: list[Finding] = []
|
|
80
|
+
for finding in findings:
|
|
81
|
+
key = fingerprint(finding)
|
|
82
|
+
available = remaining.get(key, 0)
|
|
83
|
+
if available:
|
|
84
|
+
remaining[key] = available - 1
|
|
85
|
+
continue
|
|
86
|
+
kept.append(finding)
|
|
87
|
+
return tuple(kept), len(findings) - len(kept)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def fingerprint(finding: Finding) -> Key:
|
|
91
|
+
"""Ключ находки, устойчивый к сдвигу строк."""
|
|
92
|
+
excerpt = " ".join((finding.excerpt or "").split())
|
|
93
|
+
digest = sha256(excerpt.encode("utf-8")).hexdigest()[:FINGERPRINT_LENGTH]
|
|
94
|
+
return (str(finding.path), finding.rule_id, digest)
|
nk/core/diagnostics.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Внутренние диагностики линтера.
|
|
2
|
+
|
|
3
|
+
Это не правила: они не проверяют требований стандарта, не имеют пункта и фикстур.
|
|
4
|
+
Но отключать их профилем и ключом ``--ignore`` нужно так же, как правила, поэтому
|
|
5
|
+
их идентификаторы перечислены здесь.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from nk.core.finding import Severity
|
|
11
|
+
|
|
12
|
+
#: Диагностики не ссылаются на пункт стандарта.
|
|
13
|
+
NO_CLAUSE = ""
|
|
14
|
+
|
|
15
|
+
INPUT_MISSING = "NK-PARSE-001"
|
|
16
|
+
INPUT_CYCLE = "NK-PARSE-002"
|
|
17
|
+
ENVIRONMENT_UNCLOSED = "NK-PARSE-003"
|
|
18
|
+
ENVIRONMENT_ORPHAN_END = "NK-PARSE-004"
|
|
19
|
+
GROUP_UNCLOSED = "NK-PARSE-005"
|
|
20
|
+
ENCODING_FALLBACK = "NK-PARSE-006"
|
|
21
|
+
IGNORE_UNUSED = "NK-IGNORE-001"
|
|
22
|
+
IGNORE_UNKNOWN = "NK-IGNORE-002"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class Diagnostic:
|
|
27
|
+
code: str
|
|
28
|
+
severity: Severity
|
|
29
|
+
title: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
INTERNAL: dict[str, Diagnostic] = {
|
|
33
|
+
item.code: item
|
|
34
|
+
for item in (
|
|
35
|
+
Diagnostic(INPUT_MISSING, Severity.INFO, "Включаемый файл не найден"),
|
|
36
|
+
Diagnostic(INPUT_CYCLE, Severity.INFO, "Циклическое включение файла"),
|
|
37
|
+
Diagnostic(ENVIRONMENT_UNCLOSED, Severity.INFO, "Окружение не закрыто"),
|
|
38
|
+
Diagnostic(ENVIRONMENT_ORPHAN_END, Severity.INFO, "\\end без парного \\begin"),
|
|
39
|
+
Diagnostic(GROUP_UNCLOSED, Severity.INFO, "Не закрыта скобка аргумента"),
|
|
40
|
+
Diagnostic(ENCODING_FALLBACK, Severity.INFO, "Файл не в UTF-8"),
|
|
41
|
+
Diagnostic(IGNORE_UNUSED, Severity.WARNING, "Подавление ничего не подавило"),
|
|
42
|
+
Diagnostic(IGNORE_UNKNOWN, Severity.WARNING, "Подавление ссылается на неизвестное правило"),
|
|
43
|
+
)
|
|
44
|
+
}
|