линтеры скриптов: 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
View File
@@ -1,2 +1,4 @@
*.pyc *.pyc
__pycache__/ __pycache__/
.venv/
.ruff_cache/
+64
View File
@@ -695,3 +695,67 @@ healthlog.
желательным. Пересобрать на шаге 1.7. желательным. Пересобрать на шаге 1.7.
39. **Замер — единственный шаг, который нельзя переставить.** Всё остальное в 39. **Замер — единственный шаг, который нельзя переставить.** Всё остальное в
порядке 1–5 можно тасовать; шаг 3 стоит перед шагом 5 жёстко. порядке 1–5 можно тасовать; шаг 3 стоит перед шагом 5 жёстко.
## 9. Линтеры скриптов (2026-08-03)
### Что было
Три скрипта на python, 3600 строк, ни одной проверки. `tasks.py` — 2450 строк,
которые ходят по файловой системе, переименовывают и удаляют файлы задач.
Требование к самим скриптам прежнее и не обсуждается: **голый `python3` 3.12,
ноль внешних зависимостей** — они лежат рядом со скиллами и запускаются в
чужом проекте, где ничего ставить нельзя.
### Решено
**DD. `pyproject.toml` в корне `dev-skills`, зависимости через `uv`.** Файл
живёт только здесь и не уезжает никуда: он держит **линтеры**, а не зависимости
скриптов. Скрипты остаются запускаемыми любым `python3` — это проверено прогоном
всех операций через `/usr/bin/python3`, а не через `.venv`.
**EE. Ноль зависимостей охраняется двумя способами, и главный — второй.**
`banned-api` у ruff ловит частые соблазны по имени (`requests`, `yaml`,
`pydantic`, `click`, `rich`) — список заведомо неполный. Настоящий страж —
pyrefly: в окружении нет ничего, кроме линтеров, поэтому **любой** сторонний
импорт у него не разрешается. Первый способ даёт понятное сообщение, второй —
полноту.
**FF. Версии линтеров прибиты точно** (`ruff==0.16.1`, `pyrefly==1.2.0`) плюс
`uv.lock` в git. Обновление линтера меняет набор находок, а находки правятся
руками в скриптах, которые уезжают в чужие проекты. Обновление обязано быть
отдельной осознанной правкой, а не побочным эффектом `uv sync`.
**GG. `RUF001``RUF003` выключены.** Весь текст скриптов русский: сообщения,
докстроки, комментарии. «Похожая на латиницу кириллица» здесь норма, а не
опечатка, и три этих правила давали **311 срабатываний из 338** — шум, в котором
тонут остальные 27.
**HH. `av-dev-backlog` исключён из проверки.** Плагин помечен устаревшим и живёт
до перевода последнего проекта, после чего удаляется целиком. Шесть его находок
косметические (`os.replace`, `l` как имя), а правка замороженного кода без
тестов — риск без выгоды. Исключение уходит вместе с плагином.
**II. Голый `except Exception` разрешён только помеченный.** Правило `BLE`
включено, а два места последнего рубежа (`main` обоих скриптов, код выхода 4 по
словарю) несут `# noqa: BLE001` с причиной. Так третий такой except не
появляется молча.
### Что из этого следует
40. **Найдено и починено 27 находок ruff и 14 pyrefly.** Содержательных две:
мёртвая переменная `ques` в `check` (вычислялась и не использовалась —
вопросы проверяет `questions_open`) и два места в `check --fix`, где
`find_entry_index` может вернуть `None`, а результат идёт прямо в
`list.pop` и в `range`. Оба сегодня недостижимы, и недостижимость держалась
на рассуждении о вызывающем коде, а не на проверке.
*Поправлено по ревью:* там стоит `raise`, а не `continue`. Тихий пропуск
превратил бы сломанный инвариант в отчёт «индексы согласованы» — то есть в
враньё; громкий отказ кодом 4 честнее.
41. **`os` из `tasks.py` ушёл целиком.** `os.replace``Path.replace`,
`os.path.basename``Path.name`; импорт стал не нужен.
42. **`fail()` в `docs.py` объявлен `NoReturn`.** Без этого `read_config`
выглядел как возвращающий неинициализированное значение — и это ровно то,
что читатель кода тоже не мог знать наверняка.
43. **Проверка не входит ни в один гейт.** CI у репозитория нет, хука нет;
запускается руками командой из README. Заводить хук ради двух скриптов,
которые правятся раз в месяц, — плата ритуалом без выгоды.
+23
View File
@@ -113,4 +113,27 @@ openspec/
<plugin>/skills/<skill>/references/ что читается по ссылке из скилла <plugin>/skills/<skill>/references/ что читается по ссылке из скилла
<plugin>/skills/<skill>/scripts/ tasks.py, docs.py <plugin>/skills/<skill>/scripts/ tasks.py, docs.py
<plugin>/agents/ charter'ы сабагентов <plugin>/agents/ charter'ы сабагентов
pyproject.toml линтеры скриптов, только для этого репозитория
``` ```
## Проверка скриптов
`tasks.py` и `docs.py` запускаются **где угодно голым `python3` 3.12 без
установки чего-либо** — они лежат рядом со скиллами и работают в любом проекте.
`pyproject.toml` в корне не меняет этого: он живёт только здесь и держит
линтеры, а не зависимости скриптов.
```
uv sync # ставит ruff и pyrefly в .venv, версии прибиты точно
uv run ruff check . # правила; --fix для безопасных починок
uv run pyrefly check # типы
```
Ноль внешних зависимостей охраняется двумя способами: `banned-api` у ruff ловит
частые соблазны по имени, а pyrefly видит окружение, где нет ничего кроме
линтеров, и любой сторонний импорт у него не разрешается. Список запретов —
не перечень мира, настоящий страж второй.
`av-dev-backlog` из проверки исключён намеренно: плагин помечен устаревшим и
живёт до перевода последнего проекта, после чего удаляется целиком. Правки в
замороженный код — риск без выгоды.
+9
View File
@@ -72,6 +72,15 @@
- [x] `REMAINING.md` пересобрать: пункт 2 отменён, четыре вопроса закрыты, - [x] `REMAINING.md` пересобрать: пункт 2 отменён, четыре вопроса закрыты,
калибровка стала обязательной (38) калибровка стала обязательной (38)
### 1.7 Линтеры скриптов (тема 9)
- [x] `pyproject.toml`: ruff + pyrefly через `uv`, версии прибиты (DD, FF)
- [x] запрет внешних зависимостей двумя способами: `banned-api` + пустое
окружение pyrefly (EE)
- [x] `RUF001``RUF003` выключены, `av-dev-backlog` исключён (GG, HH)
- [x] починены 27 находок ruff и 14 pyrefly; `os` из `tasks.py` ушёл (40, 41)
- [x] раздел «Проверка скриптов» в `README.md`
## 2. healthlog — первая боевая проверка ## 2. healthlog — первая боевая проверка
- [ ] `canon adopt`; `docs/backlog/``docs/tasks/` - [ ] `canon adopt`; `docs/backlog/``docs/tasks/`
+2 -1
View File
@@ -23,6 +23,7 @@ import subprocess
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import NoReturn
CANON_VERSION = 1 CANON_VERSION = 1
@@ -118,7 +119,7 @@ class Report:
self.skipped.append(msg) self.skipped.append(msg)
def fail(code: int, msg: str) -> None: def fail(code: int, msg: str) -> NoReturn:
print(f"ОТКАЗ: {msg}", file=sys.stderr) print(f"ОТКАЗ: {msg}", file=sys.stderr)
sys.exit(code) sys.exit(code)
+47 -41
View File
@@ -79,7 +79,6 @@ av-dev, и подгоняется под него проект. Имена вн
import argparse import argparse
import datetime import datetime
import json import json
import os
import re import re
import subprocess import subprocess
import sys import sys
@@ -210,7 +209,7 @@ def dir_within_cwd(root: Path) -> bool:
def write_atomic(path: Path, text: str) -> None: def write_atomic(path: Path, text: str) -> None:
tmp = path.with_name(path.name + ".tmp") tmp = path.with_name(path.name + ".tmp")
tmp.write_text(text, encoding="utf-8") tmp.write_text(text, encoding="utf-8")
os.replace(tmp, path) tmp.replace(path)
class Plan: class Plan:
@@ -255,7 +254,7 @@ class Plan:
tmp.write_text(text, encoding="utf-8") tmp.write_text(text, encoding="utf-8")
staged.append((tmp, path)) staged.append((tmp, path))
for tmp, path in staged: for tmp, path in staged:
os.replace(tmp, path) tmp.replace(path)
for path in self.deletes: for path in self.deletes:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
@@ -310,7 +309,7 @@ def _read_json(path: Path) -> dict:
try: try:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise Env(f"{path}: не разбирается как JSON — {e}") raise Env(f"{path}: не разбирается как JSON — {e}") from e
if not isinstance(data, dict): if not isinstance(data, dict):
raise Env(f"{path}: ожидался объект с настройками") raise Env(f"{path}: ожидался объект с настройками")
return data return data
@@ -411,7 +410,7 @@ def parse_entries(lines: list[str]) -> tuple[dict[str, dict], list[str]]:
m = INDEX_ENTRY.match(line) m = INDEX_ENTRY.match(line)
if m: if m:
title, target, hook = m.group(1), m.group(2), (m.group(3) or "").strip() 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, "title": title, "section": section, "hook": hook,
"line": num, "target": target} "line": num, "target": target}
return entries, sections return entries, sections
@@ -434,7 +433,7 @@ def index_lint(lines: list[str], label: str) -> list[str]:
errors.append(f"{label}:{num}: строка-пункт не по формату" errors.append(f"{label}:{num}: строка-пункт не по формату"
f" «- [Заголовок](items/slug.md) — хук»") f" «- [Заголовок](items/slug.md) — хук»")
continue continue
target = os.path.basename(m.group(2)) target = Path(m.group(2)).name
if section is None: if section is None:
errors.append(f"{label}:{num}: {target} стоит до первой секции") errors.append(f"{label}:{num}: {target} стоит до первой секции")
if target in seen: if target in seen:
@@ -527,7 +526,7 @@ def touched_map(lay: Layout) -> dict[str, str]:
if DATE_RE.fullmatch(line): if DATE_RE.fullmatch(line):
cur = line # лог новейшие сверху → первая дата и есть последняя правка cur = line # лог новейшие сверху → первая дата и есть последняя правка
elif cur: elif cur:
dates.setdefault(os.path.basename(line), cur) dates.setdefault(Path(line).name, cur)
return dates 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} 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_slugs = {p[:-3] for p, t in tasks.items() if t["type"] == GOAL}
goal_of_sprint, _ = sprint_goal(lay) goal_of_sprint, _ = sprint_goal(lay)
ques = lay.cfg["questions_heading"].lower()
for s in sections["backlog"]: for s in sections["backlog"]:
if s.lower() in BLOCKER_SECTIONS: 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]: elif task["section"] not in known[home]:
errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной" errors.append(f"{name}: секция «{task['section']}» не совпадает ни с одной"
f" секцией {label[home]} ({', '.join(sections[home])})") 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"]: and entry["section"].lower() != task["section"]:
errors.append(f"{name}: секция в файле «{task['section']}»," errors.append(f"{name}: секция в файле «{task['section']}»,"
f" а в {label[place]} — «{entry['section']}»") f" а в {label[place]} — «{entry['section']}»")
@@ -708,7 +706,7 @@ def check(lay: Layout, fix: bool = False) -> int:
errors.append(f"{name}: хук разошёлся (истина в файле)\n" errors.append(f"{name}: хук разошёлся (истина в файле)\n"
f" файл: {task['hook']}\n" f" файл: {task['hook']}\n"
f" индекс: {entry['hook']}") 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]}, а в файле нет —" errors.append(f"{name}: хук есть в {label[place]}, а в файле нет —"
f" `check --fix` перенесёт его в мета-строку") f" `check --fix` перенесёт его в мета-строку")
elif not task["hook"]: 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]]: 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]: 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: def find_entry_index(lines: list[str], slug: str) -> int | None:
for i, l in enumerate(lines): for i, line in enumerate(lines):
m = INDEX_ENTRY.match(l) m = INDEX_ENTRY.match(line)
if m and Path(m.group(2)).name == f"{slug}.md": if m and Path(m.group(2)).name == f"{slug}.md":
return i return i
return None 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) 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()): if mi is None or not META_FIELD.match(flines[mi].strip()):
return None return None
chunks: list[str | None] = flines[mi].split("·") chunks: list[str | None] = [*flines[mi].split("·")]
seen_section = seen_tags = seen_hook = False seen_section = seen_tags = seen_hook = False
tags_at = None tags_at = None
for idx, chunk in enumerate(chunks): 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: if not f:
continue continue
key = f.group(1).strip().lower() key = f.group(1).strip().lower()
@@ -1230,7 +1230,7 @@ def cmd_edit(lay: Layout, a: argparse.Namespace) -> int:
plan.file(path, new_text) plan.file(path, new_text)
if old_home != new_home: if old_home != new_home:
old_lines, ei = places[old_home] old_lines, ei = places[old_home]
entry = old_lines.pop(ei) old_lines.pop(ei) # строка в новом индексе собирается заново
plan.index(lay, old_home, old_lines) plan.index(lay, old_home, old_lines)
target_lines = read_lines(lay.index(new_home)) target_lines = read_lines(lay.index(new_home))
insert_entry(target_lines, section, entry_line(lay, h1, a.slug, hook)) 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) entry = lines.pop(ei)
try: try:
insert_entry(lines, section, entry, a.after, a.first) insert_entry(lines, section, entry, a.after, a.first)
except KeyError: except KeyError as e:
raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет") raise Usage(f"--after {a.after}: такой строки в секции «{section}» нет") from e
plan = Plan() plan = Plan()
plan.file(path, new_text) 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()): if any(f"{SPRINT_TAG}{slug}" in t["tags"] for t in tasks.values()):
print(f" внимание: тег {SPRINT_TAG}{slug} уже стоит на задачах прошлого спринта" print(f" внимание: тег {SPRINT_TAG}{slug} уже стоит на задачах прошлого спринта"
f" — урожаи склеятся; задай другой `--slug`") f" — урожаи склеятся; задай другой `--slug`")
lines = sprint_header(lay, a.goal, goal["title"], date, slug) \ lines = [*sprint_header(lay, a.goal, goal["title"], date, slug),
+ [f"## {lay.cfg['sprint_section']}", ""] f"## {lay.cfg['sprint_section']}", ""]
plan = Plan() plan = Plan()
plan.index(lay, "sprint", lines) plan.index(lay, "sprint", lines)
plan.commit() plan.commit()
@@ -1591,8 +1591,8 @@ def cmd_sprint_drop(lay: Layout, a: argparse.Namespace) -> int:
plan.index(lay, "sprint", sprint_lines) plan.index(lay, "sprint", sprint_lines)
plan.commit() plan.commit()
print(f"вышло из спринта: {', '.join(dropped)} — причина записана в мета-строку") print(f"вышло из спринта: {', '.join(dropped)} — причина записана в мета-строку")
print(f" живого предложения оставаться не должно; наработки, которые жалко," print(" живого предложения оставаться не должно; наработки, которые жалко,"
f" переносятся в тело задачи текстом") " переносятся в тело задачи текстом")
return EXIT_OK return EXIT_OK
@@ -1656,22 +1656,22 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
for kind, lines in idx.items(): for kind, lines in idx.items():
seen: set[str] = set() seen: set[str] = set()
out: list[str] = [] out: list[str] = []
for l in lines: for line in lines:
m = INDEX_ENTRY.match(l) m = INDEX_ENTRY.match(line)
if m and Path(m.group(2)).name in seen: if m and Path(m.group(2)).name in seen:
fixed.append(f"{lay.name(kind)}: убран дубль строки {Path(m.group(2)).name}") fixed.append(f"{lay.name(kind)}: убран дубль строки {Path(m.group(2)).name}")
dirty.add(kind) dirty.add(kind)
continue continue
if m: if m:
seen.add(Path(m.group(2)).name) seen.add(Path(m.group(2)).name)
out.append(l) out.append(line)
idx[kind] = out idx[kind] = out
# 2. Хук: истина в файле. Если в файле его нет, а в индексе есть — это # 2. Хук: истина в файле. Если в файле его нет, а в индексе есть — это
# старый формат, и единственный экземпляр надо спасти в файл. # старый формат, и единственный экземпляр надо спасти в файл.
for kind, lines in idx.items(): for kind, lines in idx.items():
for i, l in enumerate(lines): for i, line in enumerate(lines):
m = INDEX_ENTRY.match(l) m = INDEX_ENTRY.match(line)
if not m: if not m:
continue continue
name = Path(m.group(2)).name 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]))})") f" (есть: {', '.join(n for _, n in section_headers(idx[home]))})")
continue continue
ei = find_entry_index(idx[kind], name[:-3]) ei = find_entry_index(idx[kind], name[:-3])
entry = idx[kind].pop(ei) if ei is None: # сюда попали по where — строка обязана быть
insert_entry(idx[home], section, entry) 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)}" fixed.append(f"{name}: строка перенесена {lay.name(kind)}{lay.name(home)}"
f" (секция «{section}») — по типу «{task['type']}» её место там") f" (секция «{section}») — по типу «{task['type']}» её место там")
dirty.add(kind) dirty.add(kind)
@@ -1744,8 +1746,11 @@ def apply_fixes(lay: Layout) -> tuple[list[str], list[str]]:
if hi is None: if hi is None:
continue continue
ei = find_entry_index(idx[kind], name[:-3]) ei = find_entry_index(idx[kind], name[:-3])
cur = next((SECTION.match(idx[kind][j]).group(1) if ei is None:
for j in range(ei, -1, -1) if SECTION.match(idx[kind][j])), 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(): if cur and cur.lower() != section.lower():
insert_entry(idx[kind], section, idx[kind].pop(ei)) insert_entry(idx[kind], section, idx[kind].pop(ei))
fixed.append(f"{lay.name(kind)}: перенесена в секцию «{section}»: {name}") 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")] = ( out[lay.index("rejected")] = (
"# Ушедшее без реализации\n\n" "# Ушедшее без реализации\n\n"
"Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n" "Задачи, покинувшие беклог **без реализации**, с причиной и датой.\n"
f"Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них\n" "Пишется `tasks.py close --reason`. Реализованные сюда не идут — у них\n"
"есть коммит. Это первое место, куда смотрит дедупликация при заведении.\n\n" "есть коммит. Это первое место, куда смотрит дедупликация при заведении.\n\n"
"<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->\n") "<!-- - ГГГГ-ММ-ДД `slug` — Заголовок. Причина: … Была секция: … -->\n")
return out return out
@@ -1910,12 +1915,13 @@ def scan_old_backlog(src: Path) -> dict:
"questions_heading": qh, "questions_heading": qh,
"source": str(path), "source": str(path),
}) })
for name, entry in entries.items(): if index_name:
if name not in seen: for name, entry in entries.items():
found["unclassified"].append({ if name not in seen:
"what": f"строка индекса «{entry['title']}» → {name}", found["unclassified"].append({
"where": f"{src / index_name}:{entry['line']}", "what": f"строка индекса «{entry['title']}» → {name}",
"why": "файла нет — переносить нечего, текст только в строке"}) "where": f"{src / index_name}:{entry['line']}",
"why": "файла нет — переносить нечего, текст только в строке"})
if graveyard: if graveyard:
for line in read_lines(src / graveyard): for line in read_lines(src / graveyard):
if line.startswith("- "): if line.startswith("- "):
@@ -2140,7 +2146,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
try: try:
pl = json.loads(plan_path.read_text(encoding="utf-8")) pl = json.loads(plan_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e: 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"]) root = Path(pl["target"])
if not dir_within_cwd(root): if not dir_within_cwd(root):
@@ -2264,7 +2270,7 @@ def cmd_adopt_apply(a: argparse.Namespace) -> int:
wr.commit() 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 []): for r in (a.refs or []):
p = Path(r) p = Path(r)
ref_paths += sorted(p.rglob("*.md")) if p.is_dir() else [p] ref_paths += sorted(p.rglob("*.md")) if p.is_dir() else [p]
@@ -2446,6 +2452,6 @@ if __name__ == "__main__":
sys.exit(EXIT_ENV) sys.exit(EXIT_ENV)
except KeyboardInterrupt: except KeyboardInterrupt:
sys.exit(EXIT_INTERNAL) 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) print(f"внутренний сбой ({type(e).__name__}): {e}", file=sys.stderr)
sys.exit(EXIT_INTERNAL) sys.exit(EXIT_INTERNAL)
+65
View File
@@ -0,0 +1,65 @@
[project]
name = "dev-skills"
version = "0"
description = "Маркетплейс плагинов av-dev; здесь только линтеры скриптов скиллов"
requires-python = ">=3.12"
dependencies = []
# Версии прибиты точно: обновление линтера меняет набор находок, а находки тут
# правятся руками в скриптах, которые уезжают в чужие проекты. Обновление —
# отдельная осознанная правка, а не побочный эффект `uv sync`.
[dependency-groups]
dev = ["ruff==0.16.1", "pyrefly==1.2.0"]
[tool.uv]
package = false
[tool.ruff]
target-version = "py312"
line-length = 88
# backlog.py заморожен: плагин помечен УСТАРЕЛ и живёт до перевода последнего
# проекта, после чего удаляется целиком. Правки в него — риск без выгоды.
exclude = ["av-dev-backlog"]
[tool.ruff.lint]
select = [
"F", # pyflakes: неиспользованное, неопределённое, битые f-строки
"E", "W",# pycodestyle
"I", # порядок импортов
"UP", # устаревшие для 3.12 конструкции
"B", # bugbear: изменяемый дефолт, забытый raise, цикл с замыканием
"SIM", # упрощения
"RET", # возвраты
"PTH", # os.path → pathlib
"RUF", # правила самого ruff
"TID", # запрет внешних зависимостей, см. banned-api ниже
"BLE", # голый except Exception — только там, где помечен noqa
]
ignore = [
"E501", # длину строк держит formatter, а не проверка
# Весь текст скриптов — русский: сообщения, докстроки, комментарии.
# «Похожая на латиницу кириллица» здесь не опечатка, а норма, и три этих
# правила дают 311 срабатываний из 338 на пустом месте.
"RUF001", "RUF002", "RUF003",
]
[tool.ruff.lint.flake8-tidy-imports.banned-api]
# Скрипты обязаны работать на голом python 3.12 без установки чего-либо:
# они лежат рядом со скиллами и запускаются в любом проекте. Список — не
# полный перечень мира, а частые соблазны; настоящий страж — pyrefly, у
# которого в окружении нет ничего, кроме линтеров.
"requests".msg = "внешняя зависимость; скрипты работают на голом python 3.12"
"yaml".msg = "внешняя зависимость; для json есть stdlib, для yaml — разбор руками"
"pydantic".msg = "внешняя зависимость; скрипты работают на голом python 3.12"
"click".msg = "внешняя зависимость; аргументы разбирает argparse"
"rich".msg = "внешняя зависимость; вывод — обычный print"
[tool.ruff.format]
quote-style = "double"
[tool.pyrefly]
project-includes = [
"av-dev-pm/skills/tasks/scripts/tasks.py",
"av-dev-pm/skills/canon/scripts/docs.py",
]
python-version = "3.12"
Generated
+66
View File
@@ -0,0 +1,66 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "dev-skills"
version = "0"
source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "pyrefly" },
{ name = "ruff" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [
{ name = "pyrefly", specifier = "==1.2.0" },
{ name = "ruff", specifier = "==0.16.1" },
]
[[package]]
name = "pyrefly"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185", size = 6243654, upload-time = "2026-08-01T02:56:27.592Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e", size = 14026305, upload-time = "2026-08-01T02:56:02.611Z" },
{ url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d", size = 13463880, upload-time = "2026-08-01T02:56:04.93Z" },
{ url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d", size = 13907329, upload-time = "2026-08-01T02:56:07.104Z" },
{ url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd", size = 15039020, upload-time = "2026-08-01T02:56:09.208Z" },
{ url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a", size = 14986199, upload-time = "2026-08-01T02:56:11.924Z" },
{ url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1", size = 14393715, upload-time = "2026-08-01T02:56:14.143Z" },
{ url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf", size = 13933008, upload-time = "2026-08-01T02:56:16.525Z" },
{ url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe", size = 14431827, upload-time = "2026-08-01T02:56:18.645Z" },
{ url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa", size = 13229447, upload-time = "2026-08-01T02:56:20.921Z" },
{ url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170", size = 14087387, upload-time = "2026-08-01T02:56:23.188Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e", size = 13430873, upload-time = "2026-08-01T02:56:25.425Z" },
]
[[package]]
name = "ruff"
version = "0.16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" },
{ url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" },
{ url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" },
{ url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" },
{ url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" },
{ url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" },
{ url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" },
{ url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" },
{ url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" },
{ url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" },
{ url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" },
{ url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" },
{ url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" },
{ url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" },
]