kodu 2.1.2 → 2.1.3
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.
- package/AGENTS.md +23 -1
- package/dist/package.json +1 -1
- package/dist/src/commands/init/init.command.d.ts +1 -0
- package/dist/src/commands/init/init.command.js +34 -1
- package/dist/src/commands/init/init.command.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/skills/doc-gen/SKILL.md +490 -0
- package/skills/doc-gen/scripts/doc_gen.py +911 -0
- package/skills/implement-project/SKILL.md +409 -0
- package/skills/litefront-prototype/SKILL.md +484 -0
- package/skills/start/SKILL.md +319 -0
- package/skills/tech-blueprint/SKILL.md +890 -0
- package/skills/tech-blueprint/scripts/blueprint_validator.py +417 -0
- package/src/commands/init/init.command.ts +43 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
doc_gen.py — генератор и валидатор документации продукта.
|
|
4
|
+
|
|
5
|
+
Использование:
|
|
6
|
+
python3 doc_gen.py generate "НазваниеПроекта" [--only L1|L2] [--update] [--output PATH]
|
|
7
|
+
python3 doc_gen.py validate "НазваниеПроекта" [--output PATH]
|
|
8
|
+
python3 doc_gen.py consistency "НазваниеПроекта" [--output PATH]
|
|
9
|
+
python3 doc_gen.py status ["НазваниеПроекта"] [--output PATH]
|
|
10
|
+
python3 doc_gen.py update-status "НазваниеПроекта" "статус" [--output PATH]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import date
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ─── Структура документов ─────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
STRUCTURE = {
|
|
23
|
+
"INDEX.md": {
|
|
24
|
+
"description": "Оглавление",
|
|
25
|
+
"headings": ["## Навигация", "## Быстрые ссылки"],
|
|
26
|
+
},
|
|
27
|
+
"1_PRODUCT_VISION/VISION.md": {
|
|
28
|
+
"description": "Концепция продукта",
|
|
29
|
+
"headings": [
|
|
30
|
+
"## Проблема", "## Целевая аудитория", "## Цель",
|
|
31
|
+
"## Ключевые возможности", "## Метрики успеха",
|
|
32
|
+
"## Что входит", "## Что НЕ входит",
|
|
33
|
+
],
|
|
34
|
+
"min_section_chars": 60,
|
|
35
|
+
"check_metrics": True,
|
|
36
|
+
},
|
|
37
|
+
"2_PRODUCT_SPEC/SPEC.md": {
|
|
38
|
+
"description": "Спецификация продукта",
|
|
39
|
+
"headings": [
|
|
40
|
+
"## Ссылки", "## Как устроена система", "## Глоссарий",
|
|
41
|
+
"## Сущности", "## Страницы и экраны", "## Ключевые операции",
|
|
42
|
+
"## Интеграции", "## Тестирование", "## Артефакты",
|
|
43
|
+
],
|
|
44
|
+
"min_section_chars": 60,
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Секции, освобождённые от проверки минимальной длины
|
|
49
|
+
_SECTION_LEN_EXEMPT = {
|
|
50
|
+
"## Ссылки", "## Навигация", "## Быстрые ссылки", "## Интеграции",
|
|
51
|
+
"## Артефакты",
|
|
52
|
+
}
|
|
53
|
+
_MIN_SECTION_CHARS = 60
|
|
54
|
+
|
|
55
|
+
STATUS_PATTERN = re.compile(r'\*\*Статус:\*\*\s*(черновик|на ревью|утверждён)')
|
|
56
|
+
DATE_PATTERN = re.compile(r'\*\*Дата:\*\*\s*\d{4}-\d{2}-\d{2}')
|
|
57
|
+
PLACEHOLDER_PATTERN = re.compile(r'\[[^\]]+\](?!\()')
|
|
58
|
+
_UNSAFE_CHARS = re.compile(r'[/\\:*?"<>|]')
|
|
59
|
+
|
|
60
|
+
VALID_STATUSES = {"черновик", "на ревью", "утверждён"}
|
|
61
|
+
|
|
62
|
+
# Слова/фразы, запрещённые в содержимом документа (расплывчатые, рекламные, опциональные).
|
|
63
|
+
# Нормализованы: е вместо ё (проверка идёт через _norm()).
|
|
64
|
+
_FORBIDDEN: list[str] = [
|
|
65
|
+
# Опциональность
|
|
66
|
+
"при необходимости", "по желанию", "при желании", "может быть",
|
|
67
|
+
"возможно", "опционально", "наверное", "вероятно",
|
|
68
|
+
# Расплывчатые качества
|
|
69
|
+
"быстро", "быстрый", "быстрая", "быстрое",
|
|
70
|
+
"удобно", "удобный", "удобная", "удобное",
|
|
71
|
+
"легко", "легкий", "легкая", "легкое",
|
|
72
|
+
"просто", "простой", "простая", "простое",
|
|
73
|
+
"интуитивно", "интуитивный", "интуитивная",
|
|
74
|
+
"гибко", "гибкий", "гибкая",
|
|
75
|
+
"современный", "современная", "современное",
|
|
76
|
+
"инновационный", "инновационная",
|
|
77
|
+
"лучший", "лучшая", "лучшее",
|
|
78
|
+
"оптимальный", "оптимальная", "оптимальное",
|
|
79
|
+
"эффективно", "эффективный", "эффективная",
|
|
80
|
+
"качественный", "качественная", "качественное",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# Стоп-слова для анализа ключевых слов (согласованность)
|
|
84
|
+
_STOPWORDS: set[str] = {
|
|
85
|
+
"будет", "этого", "этот", "этой", "этом", "этому", "этими",
|
|
86
|
+
"который", "которой", "которая", "которые", "которых", "которым", "которого",
|
|
87
|
+
"также", "чтобы", "должен", "может", "каждый", "каждая", "каждое",
|
|
88
|
+
"должна", "должно", "через", "после", "перед", "между",
|
|
89
|
+
"пользователь", "пользователя", "пользователей", "пользователи",
|
|
90
|
+
"системы", "система", "продукт", "продукта", "список", "раздел",
|
|
91
|
+
"данные", "данных", "только", "более", "менее", "того",
|
|
92
|
+
"всего", "всех", "всем", "свою", "своих", "своем",
|
|
93
|
+
"такой", "такая", "такое", "такие", "таких", "таким",
|
|
94
|
+
"одного", "одному", "одним", "один", "одна", "одно",
|
|
95
|
+
"иметь", "делать", "делает", "делают", "выполнять", "выполняет",
|
|
96
|
+
"включает", "включают", "содержит", "содержат",
|
|
97
|
+
"возможность", "возможности", "функция", "функции", "функционал",
|
|
98
|
+
"позволяет", "нужно", "нужен", "нужна", "нужны", "можно", "нельзя",
|
|
99
|
+
"является", "являются",
|
|
100
|
+
"that", "this", "with", "from", "have", "will", "been",
|
|
101
|
+
"they", "their", "which", "when", "user", "users", "system",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ─── Цвета и вывод ────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
class C:
|
|
108
|
+
RED = "\033[0;31m"
|
|
109
|
+
GREEN = "\033[0;32m"
|
|
110
|
+
YELLOW = "\033[1;33m"
|
|
111
|
+
RESET = "\033[0m"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ok(msg: str) -> None: print(f"{C.GREEN}✓{C.RESET} {msg}")
|
|
115
|
+
def err(msg: str) -> None: print(f"{C.RED}✗{C.RESET} {msg}")
|
|
116
|
+
def warn(msg: str) -> None: print(f"{C.YELLOW}⟳{C.RESET} {msg}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _validate_name(name: str) -> list[str]:
|
|
120
|
+
issues = []
|
|
121
|
+
if not name.strip():
|
|
122
|
+
issues.append("Имя проекта не может быть пустым.")
|
|
123
|
+
if _UNSAFE_CHARS.search(name):
|
|
124
|
+
issues.append('Имя проекта содержит недопустимые символы: / \\ : * ? " < > |')
|
|
125
|
+
if " " in name:
|
|
126
|
+
issues.append(
|
|
127
|
+
f"Имя проекта содержит пробелы. Используйте CamelCase или дефис: "
|
|
128
|
+
f"«{name.replace(' ', '')}» или «{name.replace(' ', '-')}»"
|
|
129
|
+
)
|
|
130
|
+
return issues
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ─── Вспомогательные функции ──────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
def _norm(text: str) -> str:
|
|
136
|
+
"""Нормализация: ё→е, нижний регистр."""
|
|
137
|
+
return text.replace("ё", "е").replace("Ё", "Е").lower()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _section(content: str, heading: str) -> str:
|
|
141
|
+
"""Текст секции markdown от heading до следующего заголовка того же или выше уровня."""
|
|
142
|
+
pattern = re.compile(rf"^{re.escape(heading)}\b.*$", re.MULTILINE)
|
|
143
|
+
match = pattern.search(content)
|
|
144
|
+
if not match:
|
|
145
|
+
return ""
|
|
146
|
+
start = match.end()
|
|
147
|
+
level = len(re.match(r"^(#+)", heading).group(1))
|
|
148
|
+
next_h = re.search(r"^#{1," + str(level) + r"} ", content[start:], re.MULTILINE)
|
|
149
|
+
end = start + next_h.start() if next_h else len(content)
|
|
150
|
+
return content[start:end].strip()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _keywords(text: str) -> list[str]:
|
|
154
|
+
"""Значимые слова: минимум 5 символов, не из стоп-списка."""
|
|
155
|
+
words = re.findall(r"\b[а-яёА-ЯЁa-zA-Z]{5,}\b", text)
|
|
156
|
+
return [_norm(w) for w in words if _norm(w) not in _STOPWORDS]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _list_items(text: str) -> list[str]:
|
|
160
|
+
"""Текст элементов маркированного/нумерованного списка."""
|
|
161
|
+
items = []
|
|
162
|
+
for line in text.splitlines():
|
|
163
|
+
m = re.match(r"^\s*(?:\d+\.|[-*•])\s+(.+)", line)
|
|
164
|
+
if m:
|
|
165
|
+
item = re.sub(r"\*+([^*]+)\*+", r"\1", m.group(1)).strip()
|
|
166
|
+
items.append(item)
|
|
167
|
+
return items
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _table_first_col(section_text: str, skip_headers: set[str] | None = None) -> list[str]:
|
|
171
|
+
"""Значения первого столбца таблицы (пропускает заголовок и разделитель)."""
|
|
172
|
+
if skip_headers is None:
|
|
173
|
+
skip_headers = set()
|
|
174
|
+
result = []
|
|
175
|
+
for line in section_text.splitlines():
|
|
176
|
+
if "|" not in line:
|
|
177
|
+
continue
|
|
178
|
+
if re.match(r"^\s*\|[-: |]+\|\s*$", line):
|
|
179
|
+
continue
|
|
180
|
+
cols = [c.strip() for c in line.strip("|").split("|")]
|
|
181
|
+
if cols and cols[0] and cols[0] not in skip_headers:
|
|
182
|
+
result.append(cols[0])
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _check_metrics(content: str, rel_path: str) -> list[str]:
|
|
187
|
+
"""Проверяет, что целевые значения метрик содержат числа."""
|
|
188
|
+
errors = []
|
|
189
|
+
metrics_sec = _section(content, "## Метрики успеха")
|
|
190
|
+
if not metrics_sec:
|
|
191
|
+
return errors
|
|
192
|
+
for line in metrics_sec.splitlines():
|
|
193
|
+
if "|" not in line:
|
|
194
|
+
continue
|
|
195
|
+
if re.match(r"^\s*\|[-: |]+\|\s*$", line):
|
|
196
|
+
continue
|
|
197
|
+
cols = [c.strip() for c in line.strip("|").split("|")]
|
|
198
|
+
if not cols or cols[0] in ("Метрика", "Metric", ""):
|
|
199
|
+
continue
|
|
200
|
+
val_col = cols[1].strip() if len(cols) > 1 else ""
|
|
201
|
+
if not re.search(r"\d", val_col):
|
|
202
|
+
errors.append(
|
|
203
|
+
f"{rel_path}: метрика «{cols[0]}» — "
|
|
204
|
+
f"целевое значение «{val_col}» не содержит числа"
|
|
205
|
+
)
|
|
206
|
+
return errors
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ─── Шаблоны файлов ───────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
def _today() -> str:
|
|
212
|
+
return date.today().isoformat()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _index_template(name: str) -> str:
|
|
216
|
+
return f"""\
|
|
217
|
+
# Документация продукта: {name}
|
|
218
|
+
|
|
219
|
+
**Статус:** черновик | **Дата:** {_today()}
|
|
220
|
+
|
|
221
|
+
## Навигация
|
|
222
|
+
|
|
223
|
+
| Документ | Описание |
|
|
224
|
+
|----------|----------|
|
|
225
|
+
| [Концепция](./1_PRODUCT_VISION/VISION.md) | Проблема, аудитория, цель, границы проекта |
|
|
226
|
+
| [Спецификация](./2_PRODUCT_SPEC/SPEC.md) | Сущности, страницы, операции, тестирование |
|
|
227
|
+
|
|
228
|
+
## Быстрые ссылки
|
|
229
|
+
|
|
230
|
+
- [Ключевые возможности](./1_PRODUCT_VISION/VISION.md#ключевые-возможности)
|
|
231
|
+
- [Страницы и экраны](./2_PRODUCT_SPEC/SPEC.md#страницы-и-экраны)
|
|
232
|
+
- [Тестирование](./2_PRODUCT_SPEC/SPEC.md#тестирование)
|
|
233
|
+
- [Артефакты](./2_PRODUCT_SPEC/SPEC.md#артефакты)
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _vision_template(name: str) -> str:
|
|
238
|
+
return f"""\
|
|
239
|
+
# {name}
|
|
240
|
+
|
|
241
|
+
**Статус:** черновик | **Дата:** {_today()}
|
|
242
|
+
|
|
243
|
+
## Проблема
|
|
244
|
+
[Что конкретно неудобно или не работает. Контекст. 2–4 предложения.]
|
|
245
|
+
|
|
246
|
+
## Целевая аудитория
|
|
247
|
+
[Кто пользователь: роль и контекст работы. Не «все пользователи», а конкретный тип. 2–4 предложения.]
|
|
248
|
+
|
|
249
|
+
## Цель
|
|
250
|
+
[Что именно создаём и для кого. 2–4 предложения.]
|
|
251
|
+
|
|
252
|
+
## Ключевые возможности
|
|
253
|
+
1. **[Название]**: пользователь выполняет [X] → получает [Y]
|
|
254
|
+
2. **[Название]**: пользователь выполняет [X] → получает [Y]
|
|
255
|
+
|
|
256
|
+
## Метрики успеха
|
|
257
|
+
| Метрика | Целевое значение |
|
|
258
|
+
|---------|------------------|
|
|
259
|
+
| [Метрика] | [конкретное число] |
|
|
260
|
+
|
|
261
|
+
## Что входит (границы проекта)
|
|
262
|
+
Функциональность, которая будет реализована:
|
|
263
|
+
1. [Функциональность 1]
|
|
264
|
+
|
|
265
|
+
## Что НЕ входит
|
|
266
|
+
Явные исключения для предотвращения расширения границ проекта:
|
|
267
|
+
- Не включает [X]
|
|
268
|
+
- Не включает [Y]
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _spec_template(name: str) -> str:
|
|
273
|
+
return f"""\
|
|
274
|
+
# Продуктовая спецификация: {name}
|
|
275
|
+
|
|
276
|
+
**Статус:** черновик | **Дата:** {_today()}
|
|
277
|
+
|
|
278
|
+
## Ссылки
|
|
279
|
+
- Концепция: [VISION.md](../1_PRODUCT_VISION/VISION.md)
|
|
280
|
+
|
|
281
|
+
## Как устроена система
|
|
282
|
+
[Краткое описание из каких частей состоит продукт и как они взаимодействуют.
|
|
283
|
+
Пример: «Веб-приложение с личным кабинетом и административной панелью. Данные хранятся
|
|
284
|
+
централизованно, доступ — через браузер без установки приложений.»]
|
|
285
|
+
|
|
286
|
+
## Глоссарий
|
|
287
|
+
Ключевые понятия продукта. Каждый термин имеет одно точное определение без синонимов.
|
|
288
|
+
|
|
289
|
+
| Термин | Определение |
|
|
290
|
+
|--------|-------------|
|
|
291
|
+
| [Термин] | [Точное определение] |
|
|
292
|
+
|
|
293
|
+
## Сущности
|
|
294
|
+
Основные объекты, с которыми работает система. В терминах бизнеса, не базы данных.
|
|
295
|
+
|
|
296
|
+
| Сущность | Описание | Ключевые свойства |
|
|
297
|
+
|----------|----------|-------------------|
|
|
298
|
+
| Пользователь | [Описание] | [Свойство 1, Свойство 2] |
|
|
299
|
+
|
|
300
|
+
### Жизненный цикл сущностей
|
|
301
|
+
Для каждой ключевой сущности — допустимые статусы и переходы между ними.
|
|
302
|
+
|
|
303
|
+
| Сущность | Статусы | Переходы |
|
|
304
|
+
|----------|---------|----------|
|
|
305
|
+
| [Сущность] | [статус1 → статус2 → статус3] | [статус1→статус2: условие перехода] |
|
|
306
|
+
|
|
307
|
+
## Страницы и экраны
|
|
308
|
+
Исчерпывающий список страниц и экранов, которые должны быть созданы.
|
|
309
|
+
|
|
310
|
+
| Страница | Назначение | Ключевые элементы |
|
|
311
|
+
|----------|------------|-------------------|
|
|
312
|
+
| Главная | [Назначение] | [Элемент 1, Элемент 2] |
|
|
313
|
+
| Регистрация | [Назначение] | [Элемент 1, Элемент 2] |
|
|
314
|
+
|
|
315
|
+
## Ключевые операции
|
|
316
|
+
Что пользователи могут делать в системе.
|
|
317
|
+
|
|
318
|
+
**[Роль или «Все пользователи»]:**
|
|
319
|
+
- [Операция]: [краткое описание результата]
|
|
320
|
+
|
|
321
|
+
## Интеграции
|
|
322
|
+
Внешние сервисы, без которых продукт не работает.
|
|
323
|
+
Если интеграций нет — удалить таблицу и написать: «Интеграций нет.»
|
|
324
|
+
|
|
325
|
+
| Сервис | Назначение |
|
|
326
|
+
|--------|------------|
|
|
327
|
+
| [Название] | [Зачем нужен] |
|
|
328
|
+
|
|
329
|
+
## Тестирование
|
|
330
|
+
Функциональность, покрытая тестами.
|
|
331
|
+
|
|
332
|
+
**Критические сценарии** (обязаны работать без ошибок):
|
|
333
|
+
- [Пользователь выполняет X → система возвращает Y]
|
|
334
|
+
|
|
335
|
+
**Бизнес-правила** (корректность расчётов и ограничений):
|
|
336
|
+
- [Правило или расчёт]
|
|
337
|
+
|
|
338
|
+
**Негативные сценарии** (поведение системы при ошибках и отменах):
|
|
339
|
+
- [Пользователь выполняет X с ошибочными данными → система возвращает сообщение Z, действие не выполняется]
|
|
340
|
+
|
|
341
|
+
## Артефакты
|
|
342
|
+
Вспомогательные материалы для разработки и запуска продукта.
|
|
343
|
+
Если артефактов нет — написать: «Артефактов нет.»
|
|
344
|
+
|
|
345
|
+
Артефактов нет.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ─── Генерация ────────────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
def _should_write(path: Path, update_mode: bool) -> bool:
|
|
352
|
+
if update_mode and path.exists():
|
|
353
|
+
warn(f"Пропущен (уже существует): {path.name}")
|
|
354
|
+
return False
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def cmd_generate(args: argparse.Namespace) -> int:
|
|
359
|
+
name_errors = _validate_name(args.name)
|
|
360
|
+
if name_errors:
|
|
361
|
+
for e in name_errors:
|
|
362
|
+
err(e)
|
|
363
|
+
return 1
|
|
364
|
+
|
|
365
|
+
output_dir = Path(args.output)
|
|
366
|
+
target_dir = output_dir / args.name
|
|
367
|
+
only = (args.only or "").upper()
|
|
368
|
+
|
|
369
|
+
if not args.update and target_dir.exists() and not only:
|
|
370
|
+
print(f"{C.RED}Ошибка:{C.RESET} папка {target_dir} уже существует.")
|
|
371
|
+
print("Используйте --update для дополнения без перезаписи.")
|
|
372
|
+
return 1
|
|
373
|
+
|
|
374
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
print(f"\n{C.GREEN}📁{C.RESET} {args.name} → {target_dir}\n")
|
|
376
|
+
|
|
377
|
+
# INDEX.md
|
|
378
|
+
index_path = target_dir / "INDEX.md"
|
|
379
|
+
if _should_write(index_path, args.update):
|
|
380
|
+
index_path.write_text(_index_template(args.name), encoding="utf-8")
|
|
381
|
+
ok("INDEX.md")
|
|
382
|
+
|
|
383
|
+
# L1 — Концепция
|
|
384
|
+
if not only or only == "L1":
|
|
385
|
+
l1_dir = target_dir / "1_PRODUCT_VISION"
|
|
386
|
+
l1_dir.mkdir(exist_ok=True)
|
|
387
|
+
vision_path = l1_dir / "VISION.md"
|
|
388
|
+
if _should_write(vision_path, args.update):
|
|
389
|
+
vision_path.write_text(_vision_template(args.name), encoding="utf-8")
|
|
390
|
+
ok("1_PRODUCT_VISION/VISION.md")
|
|
391
|
+
|
|
392
|
+
# L2 — Спецификация
|
|
393
|
+
if not only or only == "L2":
|
|
394
|
+
l2_dir = target_dir / "2_PRODUCT_SPEC"
|
|
395
|
+
l2_dir.mkdir(exist_ok=True)
|
|
396
|
+
spec_path = l2_dir / "SPEC.md"
|
|
397
|
+
if _should_write(spec_path, args.update):
|
|
398
|
+
spec_path.write_text(_spec_template(args.name), encoding="utf-8")
|
|
399
|
+
ok("2_PRODUCT_SPEC/SPEC.md")
|
|
400
|
+
|
|
401
|
+
# 3_ARTIFACTS/ — НЕ создаётся автоматически.
|
|
402
|
+
# Папку и подпапки создавать только при размещении реального файла-артефакта.
|
|
403
|
+
|
|
404
|
+
print(f"\n{C.GREEN}✅ Готово:{C.RESET} {target_dir}")
|
|
405
|
+
print(f"{C.YELLOW}Следующий шаг:{C.RESET} заполните документы, затем запустите валидацию:")
|
|
406
|
+
print(f" python3 <путь-к-скиллу>/scripts/doc_gen.py validate {args.name!r} --output {args.output!r}\n")
|
|
407
|
+
return 0
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ─── Валидация структуры ──────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
def _check_file(rel_path: str, file_path: Path, spec: dict) -> list[str]:
|
|
413
|
+
errors: list[str] = []
|
|
414
|
+
|
|
415
|
+
if not file_path.exists():
|
|
416
|
+
return [f"Файл отсутствует: {rel_path}"]
|
|
417
|
+
|
|
418
|
+
content = file_path.read_text(encoding="utf-8")
|
|
419
|
+
lines = content.splitlines()
|
|
420
|
+
|
|
421
|
+
# Статус и дата
|
|
422
|
+
if not STATUS_PATTERN.search(content):
|
|
423
|
+
errors.append(f"{rel_path}: строка «**Статус:** черновик|на ревью|утверждён» отсутствует")
|
|
424
|
+
if not DATE_PATTERN.search(content):
|
|
425
|
+
errors.append(f"{rel_path}: строка «**Дата:** YYYY-MM-DD» отсутствует")
|
|
426
|
+
|
|
427
|
+
# Обязательные разделы
|
|
428
|
+
for heading in spec["headings"]:
|
|
429
|
+
if not any(line.strip().startswith(heading) for line in lines):
|
|
430
|
+
errors.append(f"{rel_path}: отсутствует обязательный раздел «{heading}»")
|
|
431
|
+
|
|
432
|
+
# Заглушки + запрещённые слова (построчно, вне code-блоков)
|
|
433
|
+
in_code_block = False
|
|
434
|
+
for lineno, line in enumerate(lines, 1):
|
|
435
|
+
stripped = line.strip()
|
|
436
|
+
if stripped.startswith("```"):
|
|
437
|
+
in_code_block = not in_code_block
|
|
438
|
+
continue
|
|
439
|
+
if in_code_block:
|
|
440
|
+
continue
|
|
441
|
+
|
|
442
|
+
if PLACEHOLDER_PATTERN.search(stripped):
|
|
443
|
+
errors.append(f"{rel_path}:{lineno}: незаполненная заглушка → {stripped[:80]}")
|
|
444
|
+
|
|
445
|
+
line_norm = _norm(stripped)
|
|
446
|
+
for phrase in _FORBIDDEN:
|
|
447
|
+
phrase_norm = _norm(phrase)
|
|
448
|
+
if " " in phrase_norm:
|
|
449
|
+
if phrase_norm in line_norm:
|
|
450
|
+
errors.append(
|
|
451
|
+
f"{rel_path}:{lineno}: запрещённая фраза «{phrase}» → {stripped[:80]}"
|
|
452
|
+
)
|
|
453
|
+
break
|
|
454
|
+
else:
|
|
455
|
+
if re.search(rf"\b{re.escape(phrase_norm)}\b", line_norm):
|
|
456
|
+
errors.append(
|
|
457
|
+
f"{rel_path}:{lineno}: запрещённое слово «{phrase}» → {stripped[:80]}"
|
|
458
|
+
)
|
|
459
|
+
break
|
|
460
|
+
|
|
461
|
+
# Минимальная длина разделов (только файлы с min_section_chars)
|
|
462
|
+
min_chars = spec.get("min_section_chars")
|
|
463
|
+
if min_chars:
|
|
464
|
+
for heading in spec["headings"]:
|
|
465
|
+
if heading in _SECTION_LEN_EXEMPT:
|
|
466
|
+
continue
|
|
467
|
+
sec = _section(content, heading)
|
|
468
|
+
if sec and len(sec.strip()) < min_chars:
|
|
469
|
+
errors.append(
|
|
470
|
+
f"{rel_path}: раздел «{heading}» слишком короткий "
|
|
471
|
+
f"({len(sec.strip())} симв., минимум {min_chars})"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# Числа в метриках (только для файлов с check_metrics)
|
|
475
|
+
if spec.get("check_metrics"):
|
|
476
|
+
errors.extend(_check_metrics(content, rel_path))
|
|
477
|
+
|
|
478
|
+
return errors
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ─── Анализ согласованности и противоречий ────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
def _check_consistency(vision_path: Path, spec_path: Path) -> list[str]:
|
|
484
|
+
"""
|
|
485
|
+
Попарный и кросс-документный анализ согласованности.
|
|
486
|
+
Возвращает список найденных проблем.
|
|
487
|
+
"""
|
|
488
|
+
issues: list[str] = []
|
|
489
|
+
|
|
490
|
+
if not vision_path.exists() or not spec_path.exists():
|
|
491
|
+
issues.append("Невозможно проверить согласованность: один или оба файла отсутствуют.")
|
|
492
|
+
return issues
|
|
493
|
+
|
|
494
|
+
vision = vision_path.read_text(encoding="utf-8")
|
|
495
|
+
spec = spec_path.read_text(encoding="utf-8")
|
|
496
|
+
|
|
497
|
+
includes_text = _section(vision, "## Что входит")
|
|
498
|
+
excludes_text = _section(vision, "## Что НЕ входит")
|
|
499
|
+
inc_items = _list_items(includes_text)
|
|
500
|
+
exc_items = _list_items(excludes_text)
|
|
501
|
+
|
|
502
|
+
# ── 1. Попарно: «Что входит» vs «Что НЕ входит» ──────────────────────────
|
|
503
|
+
# Флажок только если ≥2 ключевых слов совпадают И они покрывают ≥50% exc-элемента.
|
|
504
|
+
# Это исключает ложные срабатывания на разные грани одной темы.
|
|
505
|
+
for exc_item in exc_items:
|
|
506
|
+
exc_kw = set(_keywords(exc_item))
|
|
507
|
+
if len(exc_kw) < 2:
|
|
508
|
+
continue
|
|
509
|
+
for inc_item in inc_items:
|
|
510
|
+
inc_kw = set(_keywords(inc_item))
|
|
511
|
+
overlap = exc_kw & inc_kw
|
|
512
|
+
if len(overlap) >= 2 and len(overlap) / len(exc_kw) >= 0.5:
|
|
513
|
+
issues.append(
|
|
514
|
+
f"[VISION] Противоречие между «Что входит» и «Что НЕ входит»:\n"
|
|
515
|
+
f" Входит: «{inc_item[:70]}»\n"
|
|
516
|
+
f" НЕ входит: «{exc_item[:70]}»\n"
|
|
517
|
+
f" Общие слова: {', '.join(sorted(overlap))}"
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# ── 2. «Что НЕ входит» vs SPEC операции/страницы ─────────────────────────
|
|
521
|
+
ops_text = _section(spec, "## Ключевые операции")
|
|
522
|
+
pages_text = _section(spec, "## Страницы и экраны")
|
|
523
|
+
spec_func = _norm(ops_text + " " + pages_text)
|
|
524
|
+
|
|
525
|
+
for exc_item in exc_items:
|
|
526
|
+
exc_kw = _keywords(exc_item)
|
|
527
|
+
found = [kw for kw in exc_kw if kw in spec_func]
|
|
528
|
+
if len(found) >= 2:
|
|
529
|
+
issues.append(
|
|
530
|
+
f"[VISION→SPEC] Противоречие: исключённый элемент «{exc_item[:70]}» "
|
|
531
|
+
f"обнаружен в SPEC (слова: {', '.join(found[:5])})"
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# ── 3. «Ключевые возможности» VISION → покрытие в SPEC ───────────────────
|
|
535
|
+
capabilities_text = _section(vision, "## Ключевые возможности")
|
|
536
|
+
spec_norm = _norm(spec)
|
|
537
|
+
|
|
538
|
+
for cap_item in _list_items(capabilities_text):
|
|
539
|
+
cap_kw = _keywords(cap_item)
|
|
540
|
+
if not cap_kw:
|
|
541
|
+
continue
|
|
542
|
+
found_n = sum(1 for kw in cap_kw if kw in spec_norm)
|
|
543
|
+
coverage = found_n / len(cap_kw)
|
|
544
|
+
if coverage < 0.4:
|
|
545
|
+
short = re.sub(r"^\d+\.\s*", "", cap_item)[:70]
|
|
546
|
+
issues.append(
|
|
547
|
+
f"[VISION→SPEC] Несогласованность: возможность «{short}» "
|
|
548
|
+
f"слабо отражена в SPEC ({found_n}/{len(cap_kw)} ключевых слов)"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# ── 4. Пункты «Что входит» → покрытие в SPEC ─────────────────────────────
|
|
552
|
+
for inc_item in inc_items:
|
|
553
|
+
inc_kw = _keywords(inc_item)
|
|
554
|
+
if not inc_kw:
|
|
555
|
+
continue
|
|
556
|
+
found_n = sum(1 for kw in inc_kw if kw in spec_norm)
|
|
557
|
+
coverage = found_n / len(inc_kw)
|
|
558
|
+
if coverage < 0.35:
|
|
559
|
+
issues.append(
|
|
560
|
+
f"[VISION→SPEC] Несогласованность: «Что входит» «{inc_item[:60]}» "
|
|
561
|
+
f"не отражён в SPEC ({found_n}/{len(inc_kw)} ключевых слов)"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# ── 5. Сущности SPEC → упоминание в «Ключевые операции» ──────────────────
|
|
565
|
+
entities_text = _section(spec, "## Сущности")
|
|
566
|
+
entity_names = _table_first_col(entities_text, skip_headers={"Сущность", "Entity"})
|
|
567
|
+
ops_norm = _norm(ops_text)
|
|
568
|
+
|
|
569
|
+
for entity in entity_names:
|
|
570
|
+
if _norm(entity) not in ops_norm:
|
|
571
|
+
issues.append(
|
|
572
|
+
f"[SPEC] Несогласованность: сущность «{entity}» "
|
|
573
|
+
f"не упоминается в «Ключевые операции»"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# ── 6. «Тестирование» — обязательные три подраздела ──────────────────────
|
|
577
|
+
testing_text = _section(spec, "## Тестирование")
|
|
578
|
+
for sub in ("**Критические сценарии**", "**Бизнес-правила**", "**Негативные сценарии**"):
|
|
579
|
+
if sub not in testing_text:
|
|
580
|
+
issues.append(
|
|
581
|
+
f"[SPEC] «Тестирование» не содержит обязательный подраздел {sub}"
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
# ── 7. Глоссарий → термины используются где-то в документации ────────────
|
|
585
|
+
glossary_text = _section(spec, "## Глоссарий")
|
|
586
|
+
glossary_terms = _table_first_col(glossary_text, skip_headers={"Термин", "Term"})
|
|
587
|
+
spec_no_gloss = _norm(spec.replace(glossary_text, ""))
|
|
588
|
+
vision_norm = _norm(vision)
|
|
589
|
+
|
|
590
|
+
for term in glossary_terms:
|
|
591
|
+
term_norm = _norm(term)
|
|
592
|
+
if term_norm not in spec_no_gloss and term_norm not in vision_norm:
|
|
593
|
+
issues.append(
|
|
594
|
+
f"[SPEC] Глоссарий: термин «{term}» определён, "
|
|
595
|
+
f"но нигде не используется в документации"
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
# ── 8. Попарно: «Цель» vs «Что НЕ входит» ────────────────────────────────
|
|
599
|
+
goal_text = _section(vision, "## Цель")
|
|
600
|
+
goal_norm = _norm(goal_text)
|
|
601
|
+
|
|
602
|
+
for exc_item in exc_items:
|
|
603
|
+
exc_kw = set(_keywords(exc_item))
|
|
604
|
+
if len(exc_kw) < 2:
|
|
605
|
+
continue
|
|
606
|
+
overlap = {kw for kw in exc_kw if kw in goal_norm}
|
|
607
|
+
if len(overlap) >= 2 and len(overlap) / len(exc_kw) >= 0.5:
|
|
608
|
+
issues.append(
|
|
609
|
+
f"[VISION] Возможное противоречие: раздел «Цель» конфликтует с «Что НЕ входит»:\n"
|
|
610
|
+
f" НЕ входит: «{exc_item[:70]}»\n"
|
|
611
|
+
f" Совпавшие слова в «Цели»: {', '.join(sorted(overlap))}"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
return issues
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
# ─── Проверка артефактов ──────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
def _check_artifacts(target_dir: Path) -> list[str]:
|
|
620
|
+
"""Каждый файл в 3_ARTIFACTS/ должен быть упомянут хотя бы в одном документе."""
|
|
621
|
+
artifacts_dir = target_dir / "3_ARTIFACTS"
|
|
622
|
+
if not artifacts_dir.exists():
|
|
623
|
+
return []
|
|
624
|
+
|
|
625
|
+
doc_texts: list[str] = []
|
|
626
|
+
for rel in ("INDEX.md", "1_PRODUCT_VISION/VISION.md", "2_PRODUCT_SPEC/SPEC.md"):
|
|
627
|
+
fp = target_dir / rel
|
|
628
|
+
if fp.exists():
|
|
629
|
+
doc_texts.append(fp.read_text(encoding="utf-8"))
|
|
630
|
+
combined = "\n".join(doc_texts)
|
|
631
|
+
|
|
632
|
+
issues: list[str] = []
|
|
633
|
+
for artifact in sorted(artifacts_dir.rglob("*")):
|
|
634
|
+
if not artifact.is_file():
|
|
635
|
+
continue
|
|
636
|
+
rel_str = str(artifact.relative_to(target_dir)).replace("\\", "/")
|
|
637
|
+
if artifact.name not in combined and rel_str not in combined:
|
|
638
|
+
issues.append(
|
|
639
|
+
f"[ARTIFACTS] Артефакт «{rel_str}» не упомянут ни в одном документе"
|
|
640
|
+
)
|
|
641
|
+
return issues
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# ─── Команды ──────────────────────────────────────────────────────────────────
|
|
645
|
+
|
|
646
|
+
def cmd_consistency(args: argparse.Namespace) -> int:
|
|
647
|
+
name_errors = _validate_name(args.name)
|
|
648
|
+
if name_errors:
|
|
649
|
+
for e in name_errors:
|
|
650
|
+
err(e)
|
|
651
|
+
return 1
|
|
652
|
+
|
|
653
|
+
output_dir = Path(args.output)
|
|
654
|
+
target_dir = output_dir / args.name
|
|
655
|
+
vision_path = target_dir / "1_PRODUCT_VISION" / "VISION.md"
|
|
656
|
+
spec_path = target_dir / "2_PRODUCT_SPEC" / "SPEC.md"
|
|
657
|
+
|
|
658
|
+
print(f"\nАнализ согласованности: {args.name}\n")
|
|
659
|
+
|
|
660
|
+
issues = _check_consistency(vision_path, spec_path)
|
|
661
|
+
if not issues:
|
|
662
|
+
ok("Противоречий и несогласованностей не обнаружено.")
|
|
663
|
+
print(f"\n{C.GREEN}✅ Документация «{args.name}» согласована.{C.RESET}\n")
|
|
664
|
+
return 0
|
|
665
|
+
|
|
666
|
+
for issue in issues:
|
|
667
|
+
err(issue)
|
|
668
|
+
print(
|
|
669
|
+
f"\n{C.RED}Итог: {len(issues)} проблем согласованности. "
|
|
670
|
+
f"Устраните их перед финализацией.{C.RESET}\n"
|
|
671
|
+
)
|
|
672
|
+
return 1
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
676
|
+
output_dir = Path(args.output)
|
|
677
|
+
|
|
678
|
+
if args.name:
|
|
679
|
+
name_errors = _validate_name(args.name)
|
|
680
|
+
if name_errors:
|
|
681
|
+
for e in name_errors:
|
|
682
|
+
err(e)
|
|
683
|
+
return 1
|
|
684
|
+
projects = [args.name]
|
|
685
|
+
else:
|
|
686
|
+
if not output_dir.exists():
|
|
687
|
+
err(f"Папка не найдена: {output_dir}")
|
|
688
|
+
return 1
|
|
689
|
+
projects = sorted(d.name for d in output_dir.iterdir() if d.is_dir())
|
|
690
|
+
if not projects:
|
|
691
|
+
warn("Проекты не найдены.")
|
|
692
|
+
return 0
|
|
693
|
+
|
|
694
|
+
doc_files = ["INDEX.md", "1_PRODUCT_VISION/VISION.md", "2_PRODUCT_SPEC/SPEC.md"]
|
|
695
|
+
col1, col2, col3 = 22, 32, 14
|
|
696
|
+
|
|
697
|
+
print()
|
|
698
|
+
hdr = f"{'Проект':<{col1}} {'Файл':<{col2}} {'Статус':<{col3}} Дата"
|
|
699
|
+
print(hdr)
|
|
700
|
+
print("─" * (col1 + col2 + col3 + 14))
|
|
701
|
+
|
|
702
|
+
for proj_name in projects:
|
|
703
|
+
target_dir = output_dir / proj_name
|
|
704
|
+
for rel_path in doc_files:
|
|
705
|
+
fp = target_dir / rel_path
|
|
706
|
+
if not fp.exists():
|
|
707
|
+
print(f"{proj_name:<{col1}} {rel_path:<{col2}} {'ОТСУТСТВУЕТ':<{col3}}")
|
|
708
|
+
continue
|
|
709
|
+
content = fp.read_text(encoding="utf-8")
|
|
710
|
+
status_m = STATUS_PATTERN.search(content)
|
|
711
|
+
date_m = DATE_PATTERN.search(content)
|
|
712
|
+
status = status_m.group(1) if status_m else "?"
|
|
713
|
+
date_val = date_m.group(0).replace("**Дата:**", "").strip() if date_m else "?"
|
|
714
|
+
print(f"{proj_name:<{col1}} {rel_path:<{col2}} {status:<{col3}} {date_val}")
|
|
715
|
+
|
|
716
|
+
print()
|
|
717
|
+
return 0
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def cmd_update_status(args: argparse.Namespace) -> int:
|
|
721
|
+
if args.status not in VALID_STATUSES:
|
|
722
|
+
err(
|
|
723
|
+
f"Недопустимый статус: «{args.status}». "
|
|
724
|
+
f"Допустимые: {', '.join(sorted(VALID_STATUSES))}"
|
|
725
|
+
)
|
|
726
|
+
return 1
|
|
727
|
+
|
|
728
|
+
name_errors = _validate_name(args.name)
|
|
729
|
+
if name_errors:
|
|
730
|
+
for e in name_errors:
|
|
731
|
+
err(e)
|
|
732
|
+
return 1
|
|
733
|
+
|
|
734
|
+
output_dir = Path(args.output)
|
|
735
|
+
target_dir = output_dir / args.name
|
|
736
|
+
|
|
737
|
+
if not target_dir.exists():
|
|
738
|
+
err(f"Папка проекта не найдена: {target_dir}")
|
|
739
|
+
return 1
|
|
740
|
+
|
|
741
|
+
today = _today()
|
|
742
|
+
updated = 0
|
|
743
|
+
|
|
744
|
+
print(f"\nОбновление статуса «{args.name}» → {args.status}\n")
|
|
745
|
+
|
|
746
|
+
for rel_path in ("INDEX.md", "1_PRODUCT_VISION/VISION.md", "2_PRODUCT_SPEC/SPEC.md"):
|
|
747
|
+
fp = target_dir / rel_path
|
|
748
|
+
if not fp.exists():
|
|
749
|
+
warn(f"Пропущен (не существует): {rel_path}")
|
|
750
|
+
continue
|
|
751
|
+
content = fp.read_text(encoding="utf-8")
|
|
752
|
+
new_content = STATUS_PATTERN.sub(f"**Статус:** {args.status}", content)
|
|
753
|
+
new_content = DATE_PATTERN.sub(f"**Дата:** {today}", new_content)
|
|
754
|
+
if new_content != content:
|
|
755
|
+
fp.write_text(new_content, encoding="utf-8")
|
|
756
|
+
ok(f"{rel_path} → {args.status} | {today}")
|
|
757
|
+
updated += 1
|
|
758
|
+
else:
|
|
759
|
+
warn(f"{rel_path}: строки статуса не найдены, пропущен")
|
|
760
|
+
|
|
761
|
+
if updated:
|
|
762
|
+
print(f"\n{C.GREEN}✅ Обновлено файлов: {updated}.{C.RESET}\n")
|
|
763
|
+
print("Не забудьте создать git-коммит:")
|
|
764
|
+
print(f" git add {target_dir}/")
|
|
765
|
+
print(f' git commit -m "docs: статус {args.name} → {args.status}"\n')
|
|
766
|
+
return 0
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
770
|
+
name_errors = _validate_name(args.name)
|
|
771
|
+
if name_errors:
|
|
772
|
+
for e in name_errors:
|
|
773
|
+
err(e)
|
|
774
|
+
return 1
|
|
775
|
+
|
|
776
|
+
output_dir = Path(args.output)
|
|
777
|
+
target_dir = output_dir / args.name
|
|
778
|
+
|
|
779
|
+
if not target_dir.exists():
|
|
780
|
+
err(f"Папка проекта не найдена: {target_dir}")
|
|
781
|
+
return 1
|
|
782
|
+
|
|
783
|
+
print(f"\nВалидация документации: {args.name}\n")
|
|
784
|
+
|
|
785
|
+
# ── Блок 1: структура, заглушки, запрещённые слова, метрики ──────────────
|
|
786
|
+
print(f"{C.YELLOW}── Структура и содержимое ──────────────────{C.RESET}")
|
|
787
|
+
all_errors: list[str] = []
|
|
788
|
+
for rel_path, spec in STRUCTURE.items():
|
|
789
|
+
file_path = target_dir / rel_path
|
|
790
|
+
file_errors = _check_file(rel_path, file_path, spec)
|
|
791
|
+
if file_errors:
|
|
792
|
+
all_errors.extend(file_errors)
|
|
793
|
+
else:
|
|
794
|
+
ok(f"{rel_path} — {spec['description']}")
|
|
795
|
+
|
|
796
|
+
if all_errors:
|
|
797
|
+
print(f"\n{C.RED}Ошибки:{C.RESET}")
|
|
798
|
+
for e in all_errors:
|
|
799
|
+
err(e)
|
|
800
|
+
print(
|
|
801
|
+
f"\n{C.RED}Итог: {len(all_errors)} ошибок. "
|
|
802
|
+
f"Исправьте их перед проверкой согласованности.{C.RESET}\n"
|
|
803
|
+
)
|
|
804
|
+
return 1
|
|
805
|
+
|
|
806
|
+
# ── Блок 2: согласованность и противоречия ────────────────────────────────
|
|
807
|
+
print(f"\n{C.YELLOW}── Согласованность и противоречия ──────────{C.RESET}")
|
|
808
|
+
vision_path = target_dir / "1_PRODUCT_VISION" / "VISION.md"
|
|
809
|
+
spec_path = target_dir / "2_PRODUCT_SPEC" / "SPEC.md"
|
|
810
|
+
c_issues = _check_consistency(vision_path, spec_path)
|
|
811
|
+
|
|
812
|
+
if c_issues:
|
|
813
|
+
for issue in c_issues:
|
|
814
|
+
err(issue)
|
|
815
|
+
print(
|
|
816
|
+
f"\n{C.RED}Итог: {len(c_issues)} проблем согласованности. "
|
|
817
|
+
f"Документация не готова к финализации.{C.RESET}\n"
|
|
818
|
+
)
|
|
819
|
+
return 1
|
|
820
|
+
|
|
821
|
+
ok("Противоречий и несогласованностей не обнаружено.")
|
|
822
|
+
|
|
823
|
+
# ── Блок 3: артефакты без упоминания в документации ───────────────────────
|
|
824
|
+
artifacts_dir = target_dir / "3_ARTIFACTS"
|
|
825
|
+
has_artifacts = artifacts_dir.exists() and any(
|
|
826
|
+
f for f in artifacts_dir.rglob("*") if f.is_file()
|
|
827
|
+
)
|
|
828
|
+
if has_artifacts:
|
|
829
|
+
print(f"\n{C.YELLOW}── Артефакты ────────────────────────────────{C.RESET}")
|
|
830
|
+
a_issues = _check_artifacts(target_dir)
|
|
831
|
+
if a_issues:
|
|
832
|
+
for issue in a_issues:
|
|
833
|
+
err(issue)
|
|
834
|
+
print(
|
|
835
|
+
f"\n{C.RED}Итог: {len(a_issues)} артефактов без упоминания в документации. "
|
|
836
|
+
f"Добавьте ссылки в SPEC.md → ## Артефакты.{C.RESET}\n"
|
|
837
|
+
)
|
|
838
|
+
return 1
|
|
839
|
+
ok("Все артефакты задокументированы.")
|
|
840
|
+
|
|
841
|
+
print(
|
|
842
|
+
f"\n{C.GREEN}✅ Документация «{args.name}» прошла полную проверку "
|
|
843
|
+
f"(структура + содержимое + согласованность + артефакты).{C.RESET}\n"
|
|
844
|
+
)
|
|
845
|
+
return 0
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
|
849
|
+
|
|
850
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
851
|
+
parser = argparse.ArgumentParser(
|
|
852
|
+
prog="doc_gen.py",
|
|
853
|
+
description="Генератор и валидатор документации продукта",
|
|
854
|
+
)
|
|
855
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
856
|
+
|
|
857
|
+
# generate
|
|
858
|
+
gen = sub.add_parser("generate", help="Создать структуру документов")
|
|
859
|
+
gen.add_argument("name", metavar="НазваниеПроекта")
|
|
860
|
+
gen.add_argument("--only", choices=["L1", "L2"], metavar="L1|L2",
|
|
861
|
+
help="Создать только один уровень")
|
|
862
|
+
gen.add_argument("--update", action="store_true",
|
|
863
|
+
help="Не перезаписывать существующие файлы")
|
|
864
|
+
gen.add_argument("--output", default="./docs", metavar="PATH",
|
|
865
|
+
help="Папка вывода (по умолчанию: ./docs)")
|
|
866
|
+
|
|
867
|
+
# validate
|
|
868
|
+
val = sub.add_parser("validate", help="Полная проверка: структура + содержимое + согласованность")
|
|
869
|
+
val.add_argument("name", metavar="НазваниеПроекта")
|
|
870
|
+
val.add_argument("--output", default="./docs", metavar="PATH",
|
|
871
|
+
help="Папка с документацией (по умолчанию: ./docs)")
|
|
872
|
+
|
|
873
|
+
# consistency
|
|
874
|
+
con = sub.add_parser("consistency", help="Только анализ согласованности и противоречий")
|
|
875
|
+
con.add_argument("name", metavar="НазваниеПроекта")
|
|
876
|
+
con.add_argument("--output", default="./docs", metavar="PATH",
|
|
877
|
+
help="Папка с документацией (по умолчанию: ./docs)")
|
|
878
|
+
|
|
879
|
+
# status
|
|
880
|
+
sta = sub.add_parser("status", help="Статус документов (всех или одного проекта)")
|
|
881
|
+
sta.add_argument("name", metavar="НазваниеПроекта", nargs="?", default=None)
|
|
882
|
+
sta.add_argument("--output", default="./docs", metavar="PATH",
|
|
883
|
+
help="Папка с документацией (по умолчанию: ./docs)")
|
|
884
|
+
|
|
885
|
+
# update-status
|
|
886
|
+
upd = sub.add_parser("update-status", help="Атомарно обновить статус во всех файлах проекта")
|
|
887
|
+
upd.add_argument("name", metavar="НазваниеПроекта")
|
|
888
|
+
upd.add_argument("status", metavar="статус",
|
|
889
|
+
choices=sorted(VALID_STATUSES),
|
|
890
|
+
help="черновик | на ревью | утверждён")
|
|
891
|
+
upd.add_argument("--output", default="./docs", metavar="PATH",
|
|
892
|
+
help="Папка с документацией (по умолчанию: ./docs)")
|
|
893
|
+
|
|
894
|
+
return parser
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def main() -> None:
|
|
898
|
+
parser = build_parser()
|
|
899
|
+
args = parser.parse_args()
|
|
900
|
+
handlers = {
|
|
901
|
+
"generate": cmd_generate,
|
|
902
|
+
"validate": cmd_validate,
|
|
903
|
+
"consistency": cmd_consistency,
|
|
904
|
+
"status": cmd_status,
|
|
905
|
+
"update-status": cmd_update_status,
|
|
906
|
+
}
|
|
907
|
+
sys.exit(handlers[args.command](args))
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
if __name__ == "__main__":
|
|
911
|
+
main()
|