линтеры скриптов: 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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user