линтеры скриптов: ruff и pyrefly через uv, починены 41 находка

Скрипты остаются на голом python 3.12 без зависимостей: pyproject.toml живёт
только в этом репозитории и держит линтеры, а не зависимости скриптов.

Ноль зависимостей охраняется дважды: banned-api у ruff ловит частые соблазны по
имени, pyrefly видит окружение без ничего и не разрешает любой сторонний импорт.

Версии прибиты точно, uv.lock под git: обновление линтера меняет набор находок,
а находки правятся руками в скриптах, которые уезжают в чужие проекты.

RUF001–003 выключены — весь текст русский, 311 срабатываний из 338 шум.
av-dev-backlog исключён: заморожен до удаления, правка без выгоды.

Из 41 находки содержательных две: мёртвая ques в check и два места, где
find_entry_index может вернуть None прямо в list.pop и range. По ревью там
стоит raise, а не continue: тихий пропуск превратил бы сломанный инвариант в
отчёт «индексы согласованы».

os из tasks.py ушёл целиком, fail() в docs.py объявлен NoReturn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-08-03 15:28:10 +03:00
co-authored by Claude Opus 5
parent 68218208b7
commit c692436b91
8 changed files with 278 additions and 42 deletions
+2 -1
View File
@@ -23,6 +23,7 @@ import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import NoReturn
CANON_VERSION = 1
@@ -118,7 +119,7 @@ class Report:
self.skipped.append(msg)
def fail(code: int, msg: str) -> None:
def fail(code: int, msg: str) -> NoReturn:
print(f"ОТКАЗ: {msg}", file=sys.stderr)
sys.exit(code)
+47 -41
View File
@@ -79,7 +79,6 @@ av-dev, и подгоняется под него проект. Имена вн
import argparse
import datetime
import json
import os
import re
import subprocess
import sys
@@ -210,7 +209,7 @@ def dir_within_cwd(root: Path) -> bool:
def write_atomic(path: Path, text: str) -> None:
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(text, encoding="utf-8")
os.replace(tmp, path)
tmp.replace(path)
class Plan:
@@ -255,7 +254,7 @@ class Plan:
tmp.write_text(text, encoding="utf-8")
staged.append((tmp, path))
for tmp, path in staged:
os.replace(tmp, path)
tmp.replace(path)
for path in self.deletes:
path.unlink(missing_ok=True)
@@ -310,7 +309,7 @@ def _read_json(path: Path) -> dict:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise Env(f"{path}: не разбирается как JSON — {e}")
raise Env(f"{path}: не разбирается как JSON — {e}") from e
if not isinstance(data, dict):
raise Env(f"{path}: ожидался объект с настройками")
return data
@@ -411,7 +410,7 @@ def parse_entries(lines: list[str]) -> tuple[dict[str, dict], list[str]]:
m = INDEX_ENTRY.match(line)
if m:
title, target, hook = m.group(1), m.group(2), (m.group(3) or "").strip()
entries[os.path.basename(target)] = {
entries[Path(target).name] = {
"title": title, "section": section, "hook": hook,
"line": num, "target": target}
return entries, sections
@@ -434,7 +433,7 @@ def index_lint(lines: list[str], label: str) -> list[str]:
errors.append(f"{label}:{num}: строка-пункт не по формату"
f" «- [Заголовок](items/slug.md) — хук»")
continue
target = os.path.basename(m.group(2))
target = Path(m.group(2)).name
if section is None:
errors.append(f"{label}:{num}: {target} стоит до первой секции")
if target in seen:
@@ -527,7 +526,7 @@ def touched_map(lay: Layout) -> dict[str, str]:
if DATE_RE.fullmatch(line):
cur = line # лог новейшие сверху → первая дата и есть последняя правка
elif cur:
dates.setdefault(os.path.basename(line), cur)
dates.setdefault(Path(line).name, cur)
return dates
@@ -650,7 +649,6 @@ def check(lay: Layout, fix: bool = False) -> int:
known = {k: {s.lower() for s in sections[k]} for k in lay.indexes}
goal_slugs = {p[:-3] for p, t in tasks.items() if t["type"] == GOAL}
goal_of_sprint, _ = sprint_goal(lay)
ques = lay.cfg["questions_heading"].lower()
for s in sections["backlog"]:
if s.lower() in BLOCKER_SECTIONS:
@@ -692,7 +690,7 @@ def check(lay: Layout, fix: bool = False) -> int:
elif task["section"] not in known[home]:
errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной"
f" секцией {label[home]} ({', '.join(sections[home])})")
elif place and place != "sprint" and entry["section"] \
elif place is not None and place != "sprint" and entry and entry["section"] \
and entry["section"].lower() != task["section"]:
errors.append(f"{name}: секция в файле «{task['section']}»,"
f" а в {label[place]} — «{entry['section']}»")
@@ -708,7 +706,7 @@ def check(lay: Layout, fix: bool = False) -> int:
errors.append(f"{name}: хук разошёлся (истина в файле)\n"
f" файл: {task['hook']}\n"
f" индекс: {entry['hook']}")
elif entry and not task["hook"] and entry["hook"]:
elif place is not None and entry and not task["hook"] and entry["hook"]:
errors.append(f"{name}: хук есть в {label[place]}, а в файле нет —"
f" `check --fix` перенесёт его в мета-строку")
elif not task["hook"]:
@@ -944,7 +942,7 @@ def build_meta(section: str, reason: str, hook: str, tags: list[str]) -> str:
def section_headers(lines: list[str]) -> list[tuple[int, str]]:
return [(i, m.group(1)) for i, l in enumerate(lines) if (m := SECTION.match(l))]
return [(i, m.group(1)) for i, line in enumerate(lines) if (m := SECTION.match(line))]
def find_section(lines: list[str], name: str) -> tuple[int | None, str]:
@@ -955,8 +953,8 @@ def find_section(lines: list[str], name: str) -> tuple[int | None, str]:
def find_entry_index(lines: list[str], slug: str) -> int | None:
for i, l in enumerate(lines):
m = INDEX_ENTRY.match(l)
for i, line in enumerate(lines):
m = INDEX_ENTRY.match(line)
if m and Path(m.group(2)).name == f"{slug}.md":
return i
return None
@@ -1011,11 +1009,13 @@ def meta_updated(path: Path, section: str | None = None, reason: str | None = No
mi = next((i for i in range(1, len(flines)) if flines[i].strip()), None)
if mi is None or not META_FIELD.match(flines[mi].strip()):
return None
chunks: list[str | None] = flines[mi].split("·")
chunks: list[str | None] = [*flines[mi].split("·")]
seen_section = seen_tags = seen_hook = False
tags_at = None
for idx, chunk in enumerate(chunks):
f = META_FIELD.match((chunk or "").strip())
if chunk is None: # вычеркнутое поле — своим же проходом ниже
continue
f = META_FIELD.match(chunk.strip())
if not f:
continue
key = f.group(1).strip().lower()
@@ -1230,7 +1230,7 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
plan.file(path, new_text)
if old_home != new_home:
old_lines, ei = places[old_home]
entry = old_lines.pop(ei)
old_lines.pop(ei) # строка в новом индексе собирается заново
plan.index(lay, old_home, old_lines)
target_lines = read_lines(lay.index(new_home))
insert_entry(target_lines, section, entry_line(lay, h1, a.slug, hook))
@@ -1286,8 +1286,8 @@ def cmd_move(lay: Layout, a: argparse.Namespace) -> int:
entry = lines.pop(ei)
try:
insert_entry(lines, section, entry, a.after, a.first)
except KeyError:
raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет")
except KeyError as e:
raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет") from e
plan = Plan()
plan.file(path, new_text)
@@ -1481,8 +1481,8 @@ def cmd_sprint_start(lay: Layout, a: argparse.Namespace) -> int:
if any(f"{SPRINT_TAG}{slug}" in t["tags"] for t in tasks.values()):
print(f" внимание: тег {SPRINT_TAG}{slug} уже стоит на задачах прошлого спринта"
f" — урожаи склеятся; задай другой `--slug`")
lines = sprint_header(lay, a.goal, goal["title"], date, slug) \
+ [f"## {lay.cfg['sprint_section']}", ""]
lines = [*sprint_header(lay, a.goal, goal["title"], date, slug),
f"## {lay.cfg['sprint_section']}", ""]
plan = Plan()
plan.index(lay, "sprint", lines)
plan.commit()
@@ -1591,8 +1591,8 @@ def cmd_sprint_drop(lay: Layout, a: argparse.Namespace) -> int:
plan.index(lay, "sprint", sprint_lines)
plan.commit()
print(f"вышло из спринта: {', '.join(dropped)} — причина записана в мета-строку")
print(f" живого предложения оставаться не должно; наработки, которые жалко,"
f" переносятся в тело задачи текстом")
print(" живого предложения оставаться не должно; наработки, которые жалко,"
" переносятся в тело задачи текстом")
return EXIT_OK
@@ -1656,22 +1656,22 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
for kind, lines in idx.items():
seen: set[str] = set()
out: list[str] = []
for l in lines:
m = INDEX_ENTRY.match(l)
for line in lines:
m = INDEX_ENTRY.match(line)
if m and Path(m.group(2)).name in seen:
fixed.append(f"{lay.name(kind)}: убран дубль строки {Path(m.group(2)).name}")
dirty.add(kind)
continue
if m:
seen.add(Path(m.group(2)).name)
out.append(l)
out.append(line)
idx[kind] = out
# 2. Хук: истина в файле. Если в файле его нет, а в индексе есть — это
# старый формат, и единственный экземпляр надо спасти в файл.
for kind, lines in idx.items():
for i, l in enumerate(lines):
m = INDEX_ENTRY.match(l)
for i, line in enumerate(lines):
m = INDEX_ENTRY.match(line)
if not m:
continue
name = Path(m.group(2)).name
@@ -1731,8 +1731,10 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
f" (есть: {', '.join(n for _, n in section_headers(idx[home]))})")
continue
ei = find_entry_index(idx[kind], name[:-3])
entry = idx[kind].pop(ei)
insert_entry(idx[home], section, entry)
if ei is None: # сюда попали по where — строка обязана быть
raise RuntimeError(f"{name}: строка в {lay.name(kind)} пропала посреди"
f" прохода — чинить нечего, отчёт был бы враньём")
insert_entry(idx[home], section, idx[kind].pop(ei))
fixed.append(f"{name}: строка перенесена {lay.name(kind)}{lay.name(home)}"
f" (секция «{section}») — по типу «{task['type']}» её место там")
dirty.add(kind)
@@ -1744,8 +1746,11 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
if hi is None:
continue
ei = find_entry_index(idx[kind], name[:-3])
cur = next((SECTION.match(idx[kind][j]).group(1)
for j in range(ei, -1, -1) if SECTION.match(idx[kind][j])), None)
if ei is None:
raise RuntimeError(f"{name}: строка в {lay.name(kind)} пропала посреди"
f" прохода — чинить нечего, отчёт был бы враньём")
cur = next((m.group(1) for j in range(ei, -1, -1)
if (m := SECTION.match(idx[kind][j]))), None)
if cur and cur.lower() != section.lower():
insert_entry(idx[kind], section, idx[kind].pop(ei))
fixed.append(f"{lay.name(kind)}: перенесена в секцию «{section}»: {name}")
@@ -1800,7 +1805,7 @@ def init_files(lay: Layout, sections: list[str], plan_sections: list[str],
out[lay.index("rejected")] = (
"# Ушедшее без реализации\n\n"
"Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n"
f"Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них\n"
"Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них\n"
"есть коммит. Это первое место, куда смотрит дедупликация при заведении.\n\n"
"<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->\n")
return out
@@ -1910,12 +1915,13 @@ def scan_old_backlog(src: Path) -> dict:
"questions_heading": qh,
"source": str(path),
})
for name, entry in entries.items():
if name not in seen:
found["unclassified"].append({
"what": f"строка индекса «{entry['title']}» → {name}",
"where": f"{src / index_name}:{entry['line']}",
"why": "файла нет — переносить нечего, текст только в строке"})
if index_name:
for name, entry in entries.items():
if name not in seen:
found["unclassified"].append({
"what": f"строка индекса «{entry['title']}» → {name}",
"where": f"{src / index_name}:{entry['line']}",
"why": "файла нет — переносить нечего, текст только в строке"})
if graveyard:
for line in read_lines(src / graveyard):
if line.startswith("- "):
@@ -2140,7 +2146,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
try:
pl = json.loads(plan_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise Usage(f"{plan_path}: не разбирается как JSON — {e}")
raise Usage(f"{plan_path}: не разбирается как JSON — {e}") from e
root = Path(pl["target"])
if not dir_within_cwd(root):
@@ -2264,7 +2270,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
wr.commit()
# --- перекрёстные ссылки: тем же проходом, иначе они останутся битыми ---
ref_paths: list[Path] = list(lay.items.glob("*.md")) + [lay.index("rejected")]
ref_paths: list[Path] = [*lay.items.glob("*.md"), lay.index("rejected")]
for r in (a.refs or []):
p = Path(r)
ref_paths += sorted(p.rglob("*.md")) if p.is_dir() else [p]
@@ -2446,6 +2452,6 @@ if __name__ == "__main__":
sys.exit(EXIT_ENV)
except KeyboardInterrupt:
sys.exit(EXIT_INTERNAL)
except Exception as e: # noqa: BLE001
except Exception as e: # noqa: BLE001 — последний рубеж, код 4 по словарю
print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr)
sys.exit(EXIT_INTERNAL)