543 lines
31 KiB
Python
Executable File
543 lines
31 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Валидатор продуктового контракта KT AI (seed для kt-ai-lint).
|
||
|
||
Проверяет конфиг прототипа против product.schema.json (структура) И против правил
|
||
дизайн-системы (честность): целостность статусов, один primary, запрет ROI/FTE на
|
||
операционном дашборде, debug-язык в копирайте. Тот же контракт выдаёт Төре,
|
||
потребляет kt-ai-app-shell.html, читает handoff. ОДИН источник истины.
|
||
|
||
Запуск:
|
||
python3 scripts/validate_product.py examples/dogovor-arendy.config.json
|
||
python3 scripts/validate_product.py <config.json> --strict # warnings → ошибка
|
||
|
||
Без внешних зависимостей (stdlib). Код выхода: 0 – ок, 1 – ошибки (или warn при --strict).
|
||
"""
|
||
import glob
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
ROOT = os.path.dirname(HERE)
|
||
|
||
ERRORS, WARNINGS, NOTES = [], [], []
|
||
def err(msg): ERRORS.append(msg)
|
||
def warn(msg): WARNINGS.append(msg)
|
||
# Заметка — не претензия к конфигу, а факт о системе, который лучше узнать до
|
||
# сборки («этот архетип есть только в одном рантайме»). Считать её нарушением
|
||
# честности нельзя: под --strict предупреждения блокируют, и корректный конфиг
|
||
# падал бы из-за справки. Гейт от заметок не краснеет никогда.
|
||
def note(msg): NOTES.append(msg)
|
||
|
||
_SPRITE_IDS = None
|
||
def sprite_ids():
|
||
"""id-набор иконок из канонического спрайта (для проверки nav/onboarding icon)."""
|
||
global _SPRITE_IDS
|
||
if _SPRITE_IDS is None:
|
||
try:
|
||
with open(os.path.join(ROOT, "icons", "kt-ai-lucide-sprite.svg"), encoding="utf-8") as fh:
|
||
_SPRITE_IDS = set(re.findall(r'symbol id="([^"]+)"', fh.read()))
|
||
except OSError:
|
||
_SPRITE_IDS = set()
|
||
return _SPRITE_IDS
|
||
|
||
|
||
# ---------- мини-валидатор JSON Schema (подмножество, что используем) ----------
|
||
def validate_schema(node, schema, path="$"):
|
||
t = schema.get("type")
|
||
if t == "object" and not isinstance(node, dict):
|
||
return err(f"{path}: ожидался object, получено {type(node).__name__}")
|
||
if t == "array" and not isinstance(node, list):
|
||
return err(f"{path}: ожидался array, получено {type(node).__name__}")
|
||
if t == "string" and not isinstance(node, str):
|
||
return err(f"{path}: ожидалась string")
|
||
if t == "integer" and not isinstance(node, int):
|
||
return err(f"{path}: ожидалось integer")
|
||
if t == "boolean" and not isinstance(node, bool):
|
||
return err(f"{path}: ожидался boolean")
|
||
|
||
if "enum" in schema and node not in schema["enum"]:
|
||
err(f"{path}: значение {node!r} не из {schema['enum']}")
|
||
if isinstance(node, str):
|
||
if "maxLength" in schema and len(node) > schema["maxLength"]:
|
||
warn(f"{path}: длина {len(node)} > maxLength {schema['maxLength']} (\"{node[:40]}…\")")
|
||
if "minLength" in schema and len(node) < schema["minLength"]:
|
||
err(f"{path}: пустое значение (minLength {schema['minLength']})")
|
||
if "pattern" in schema and not re.match(schema["pattern"], node):
|
||
err(f"{path}: {node!r} не соответствует pattern {schema['pattern']}")
|
||
|
||
if isinstance(node, dict):
|
||
for r in schema.get("required", []):
|
||
if r not in node:
|
||
err(f"{path}: отсутствует обязательное поле «{r}»")
|
||
props = schema.get("properties", {})
|
||
if "minProperties" in schema and len(node) < schema["minProperties"]:
|
||
err(f"{path}: нужно ≥{schema['minProperties']} ключей")
|
||
ap = schema.get("additionalProperties", True)
|
||
for k, v in node.items():
|
||
if k in props:
|
||
validate_schema(v, props[k], f"{path}.{k}")
|
||
elif ap is False:
|
||
err(f"{path}: лишнее поле «{k}» (additionalProperties=false)")
|
||
elif isinstance(ap, dict):
|
||
validate_schema(v, ap, f"{path}.{k}")
|
||
|
||
if isinstance(node, list):
|
||
if "minItems" in schema and len(node) < schema["minItems"]:
|
||
err(f"{path}: нужно ≥{schema['minItems']} элементов")
|
||
item_schema = schema.get("items")
|
||
if item_schema:
|
||
for i, it in enumerate(node):
|
||
validate_schema(it, item_schema, f"{path}[{i}]")
|
||
|
||
|
||
# ---------- правила дизайн-системы (kt-ai-lint) ----------
|
||
FORBIDDEN_METRIC = re.compile(r"\b(roi|fte)\b|эконом|эффект|окупаем|time\s*saved|человеко-час", re.I)
|
||
DEBUG_LANG = re.compile(r"модель вернула|не проходит дизайн|\bfallback\b|\bshell\b|\bjson\b|\bprompt\b", re.I)
|
||
# «проповеднический»/мета-копирайт: дисклеймеры прототипа и нравоучения про human-in-the-loop.
|
||
# Продукт показывает решение контролами (рекомендация + кнопки), а НЕ объясняет/оправдывает себя.
|
||
PREACHY_LANG = re.compile(
|
||
r"это прототип|прототип на дизайн|данные иллюстративн|контрольная точка|"
|
||
r"(ии|ai) не действует|решение (подтверждает|принимает) человек|решение[ —-]+за вами", re.I)
|
||
|
||
|
||
def load_registry():
|
||
path = os.path.join(ROOT, "archetypes", "registry.json")
|
||
try:
|
||
return json.load(open(path, encoding="utf-8"))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _present(cfg, block):
|
||
"""Блок контракта присутствует и не пуст."""
|
||
v = cfg.get(block)
|
||
if v is None:
|
||
return False
|
||
if isinstance(v, (dict, list, str)):
|
||
return len(v) > 0
|
||
return True
|
||
|
||
|
||
def lint_archetype(cfg):
|
||
"""Реестр-управляемая проверка архетипа. Добавление архетипа = запись в registry.json,
|
||
код валидатора не меняется."""
|
||
reg = load_registry()
|
||
if not reg:
|
||
return
|
||
arch_id = cfg.get("archetype") or cfg.get("layout") or reg.get("fallback", "queue")
|
||
by_id = {a["id"]: a for a in reg.get("archetypes", [])}
|
||
fallback = reg.get("fallback", "queue")
|
||
|
||
spec = by_id.get(arch_id)
|
||
if spec is None:
|
||
warn(f"archetype: «{arch_id}» нет в реестре archetypes/registry.json — экран деградирует в «{fallback}». "
|
||
f"Известные: {sorted(by_id)}.")
|
||
spec = by_id.get(fallback)
|
||
if spec is None:
|
||
return
|
||
elif spec.get("status") == "planned":
|
||
warn(f"archetype «{arch_id}» ({spec.get('title','')}): рендерер ещё не реализован (status=planned) — "
|
||
f"экран показывается как «{fallback}».")
|
||
|
||
for block in spec.get("requires", []):
|
||
if not _present(cfg, block):
|
||
err(f"archetype «{spec['id']}» требует блок «{block}» — он отсутствует или пуст.")
|
||
for block in spec.get("forbids", []):
|
||
if _present(cfg, block):
|
||
err(f"archetype «{spec['id']}» запрещает блок «{block}».")
|
||
for block in spec.get("recommends", []):
|
||
if not _present(cfg, block):
|
||
warn(f"archetype «{spec['id']}» рекомендует блок «{block}» — без него экран беднее.")
|
||
|
||
if spec.get("id") == "compare" and not (cfg.get("compare", {}) or {}).get("pairs"):
|
||
warn("archetype compare: нет compare.pairs — нечего сверять, экран деградирует в queue.")
|
||
|
||
|
||
def lint_kpi_labels(cfg):
|
||
"""Подпись KPI начинается с прописной (Michelle v4.0: капитализация не пляшет)."""
|
||
import re as _re
|
||
for i, k in enumerate(cfg.get("kpis") or []):
|
||
lbl = (k or {}).get("label") or ""
|
||
if lbl and _re.match(r"^[а-яё]", lbl):
|
||
warn(f"kpis[{i}].label: подпись начинается со строчной («{lbl[:30]}») — начните с прописной.")
|
||
|
||
|
||
def lint_deprecated(cfg):
|
||
"""Устаревшие поля: рендерятся для старых конфигов, в новых не использовать."""
|
||
if cfg.get("filterKeys"):
|
||
warn("filterKeys: устаревшее поле — фильтры выводятся из statuses автоматически; уберите из новых конфигов.")
|
||
for i, n in enumerate(cfg.get("nav") or []):
|
||
if isinstance(n, dict) and "count" in n:
|
||
warn(f"nav[{i}].count: устаревшее поле — счётчики в меню не входят в контракт; уберите из новых конфигов.")
|
||
|
||
|
||
def lint_nav_groups(cfg):
|
||
"""Группы разделов: заголовок собирает пункты, идущие ПОДРЯД.
|
||
|
||
Одинаковое значение group у пунктов, разнесённых по списку, рисует два
|
||
одинаковых заголовка — читатель видит две группы с одним именем и решает,
|
||
что это разные вещи. Рантайм здесь честен: он ставит заголовок там, где
|
||
группа сменилась, и склеивать разорванный список за продукт не будет —
|
||
порядок разделов принадлежит продукту.
|
||
"""
|
||
nav = [n for n in (cfg.get("nav") or []) if isinstance(n, dict)]
|
||
видел, предыдущая = set(), None
|
||
for i, n in enumerate(nav):
|
||
g = n.get("group")
|
||
if g and g != предыдущая and g in видел:
|
||
warn(f"nav[{i}].group «{g}»: пункты этой группы идут не подряд — "
|
||
"заголовок нарисуется дважды. Соберите их рядом.")
|
||
if g:
|
||
видел.add(g)
|
||
предыдущая = g
|
||
|
||
# Порог Миллера: списком без устройства читается всё, что длиннее 7 строк.
|
||
if len(nav) > 7 and not any(n.get("group") for n in nav):
|
||
warn(f"nav: {len(nav)} пунктов подряд без групп — такой список читается как "
|
||
"перечень ссылок, а не как устройство продукта. Поле nav[].group "
|
||
"собирает пункты под общими заголовками (нумерация не меняется).")
|
||
|
||
|
||
def lint_banner_length(cfg):
|
||
"""Баннер — предупреждение, а не место для оговорок и выводов.
|
||
|
||
Баннер стоит НАД числами: всё, что в него уходит, человек читает до первого
|
||
числа. Продукты складывали туда оговорки к показателям (места у числа не
|
||
было) — на одном экране это дало 716 знаков сплошного текста перед данными.
|
||
Теперь места есть: оговорка о числе — kpis[].note, вывод раздела —
|
||
conclusion. Порог 200 знаков — примерно три строки на широком экране.
|
||
"""
|
||
text = ((cfg.get("banner") or {}).get("text") or "").strip()
|
||
if len(text) > 200:
|
||
warn(f"banner.text: {len(text)} знаков — это абзац до первого числа. "
|
||
"Оговорка к показателю живёт в kpis[].note (рядом с числом), "
|
||
"вывод раздела — в conclusion (после таблицы); в баннере остаётся "
|
||
"предупреждение о том, из-за чего число соврёт.")
|
||
|
||
|
||
def lint_key_refs(cfg):
|
||
"""Ключи данных, которые раньше никто не сверял: колонка и разделы inbox.
|
||
|
||
Ключ с опечаткой не ломает экран: колонка просто пустая, письмо просто без
|
||
заголовка. Разбираться в этом продукт идёт глазами по строкам, а машина
|
||
сверяет за секунду. drawer.*, chart.* и columns[].sub уже проверяет
|
||
check_data_contract — здесь только то, чего там не было.
|
||
"""
|
||
rows = ((cfg.get("table") or {}).get("rows") or [])
|
||
keys = set()
|
||
for r in rows[:200]:
|
||
if isinstance(r, dict):
|
||
keys |= {k for k in r if not k.startswith("_")}
|
||
if not keys:
|
||
return
|
||
|
||
# drawer.fields[].key и columns[].sub уже проверяются ниже, в check_data_contract.
|
||
for i, c in enumerate((cfg.get("table") or {}).get("columns") or []):
|
||
if isinstance(c, dict) and c.get("key") and c["key"] not in keys:
|
||
err(f"table.columns[{i}].key: «{c['key']}» нет в данных ряда — колонка будет пустой.")
|
||
|
||
inbox = cfg.get("inbox") or {}
|
||
for поле in ("titleKey", "subKey", "timeKey", "bodyKey"):
|
||
if inbox.get(поле) and inbox[поле] not in keys:
|
||
err(f"inbox.{поле}: «{inbox[поле]}» нет в данных ряда {sorted(keys)}")
|
||
|
||
|
||
def lint_dashboard(cfg):
|
||
"""Ряд графика привязывается к метрике по ИНДЕКСУ — индекс обязан существовать."""
|
||
db = cfg.get("dashboard") or {}
|
||
kpis = cfg.get("kpis") or []
|
||
for i, ряд in enumerate(db.get("series") or []):
|
||
if not isinstance(ряд, dict):
|
||
continue
|
||
ki = ряд.get("kpi", i)
|
||
if not isinstance(ki, int) or ki >= len(kpis):
|
||
err(f"dashboard.series[{i}].kpi: индекс {ki} вне диапазона kpis "
|
||
f"(0..{len(kpis) - 1}) — ряд не привяжется к метрике и график не откроется.")
|
||
if len(ряд.get("points") or []) < 2:
|
||
warn(f"dashboard.series[{i}]: меньше двух точек — рантайм такой ряд пропускает.")
|
||
|
||
|
||
def lint_archetype_runtimes(cfg):
|
||
"""Архетип может жить не в обоих рантаймах — сказать об этом до сборки."""
|
||
a = cfg.get("archetype") or cfg.get("layout")
|
||
if not a:
|
||
return
|
||
путь = os.path.join(ROOT, "archetypes", "registry.json")
|
||
if not os.path.exists(путь):
|
||
return
|
||
try:
|
||
реестр = {x["id"]: x for x in json.load(open(путь, encoding="utf-8"))["archetypes"]}
|
||
except (KeyError, ValueError):
|
||
return
|
||
спец = реестр.get(a)
|
||
if спец and "kit" not in (спец.get("runtimes") or []):
|
||
note(f"archetype «{a}»: реализован только в HTML-рантайме. В React-ките экран "
|
||
"деградирует (sections → custom, таблица → queue). Для React выберите "
|
||
"архетип с runtimes=kit или стройте экран на компонентах.")
|
||
|
||
|
||
def lint_kpi_semantics(cfg):
|
||
"""DS-016 (Töre, 28.08.2026): значение KPI повторяло число, которое подпись отменяет
|
||
(«сводка вместо 1,5-2 ч» → значение «1,5 ч»). Значение KPI — всегда НОВОЕ (to-be)
|
||
состояние; старое число живёт в подписи или hint."""
|
||
import re as _re
|
||
def _nums(sv):
|
||
return {n.replace(",", ".") for n in _re.findall(r"\d+(?:[.,]\d+)?", str(sv or ""))}
|
||
for i, k in enumerate(cfg.get("kpis") or []):
|
||
lbl = (k or {}).get("label") or ""
|
||
m = _re.search(r"вместо\s+(.+)$", lbl, _re.I)
|
||
if not m:
|
||
continue
|
||
old = _nums(m.group(1))
|
||
val = _nums((k or {}).get("value"))
|
||
if old and val and val <= old:
|
||
warn(f"kpis[{i}] «{lbl[:40]}»: подпись говорит «вместо {m.group(1).strip()[:20]}», "
|
||
"а значение повторяет то же число — значение KPI показывает, сколько СТАЛО; "
|
||
"старое число живёт в подписи или hint.")
|
||
|
||
|
||
def lint_rules(cfg):
|
||
lint_archetype(cfg)
|
||
lint_deprecated(cfg)
|
||
lint_kpi_labels(cfg)
|
||
lint_kpi_semantics(cfg)
|
||
lint_nav_groups(cfg)
|
||
lint_banner_length(cfg)
|
||
lint_key_refs(cfg)
|
||
lint_dashboard(cfg)
|
||
lint_archetype_runtimes(cfg)
|
||
statuses = cfg.get("statuses", {})
|
||
status_keys = set(statuses.keys())
|
||
table = cfg.get("table", {})
|
||
cols = table.get("columns", [])
|
||
col_keys = {c["key"] for c in cols if "key" in c}
|
||
status_cols = [c for c in cols if c.get("type") == "status"]
|
||
# данные ряда (колонки — лишь видимая выборка; drawer/sub могут ссылаться на любое поле ряда)
|
||
row_keys = set()
|
||
for row in table.get("rows", []):
|
||
row_keys.update(k for k in row.keys() if not str(k).startswith("_"))
|
||
data_keys = col_keys | row_keys
|
||
# col.sub должен ссылаться на реальное поле ряда
|
||
for c in cols:
|
||
if c.get("sub") and row_keys and c["sub"] not in data_keys:
|
||
err(f"table.columns «{c.get('key')}».sub: «{c['sub']}» нет в данных ряда {sorted(data_keys)}")
|
||
|
||
# tone уже проверен enum'ом в схеме.
|
||
if len(status_cols) > 1:
|
||
warn(f"table: {len(status_cols)} колонок type=status; обычно одна workflow-колонка.")
|
||
|
||
# целостность статусов в рядах
|
||
for i, row in enumerate(table.get("rows", [])):
|
||
for sc in status_cols:
|
||
v = row.get(sc["key"])
|
||
if v is not None and v not in status_keys:
|
||
err(f"table.rows[{i}].{sc['key']}: статус «{v}» не объявлен в statuses {sorted(status_keys)}")
|
||
|
||
# scenario переходы → существующие статусы
|
||
sc = cfg.get("scenario", {})
|
||
for fld in ("approveTo", "rejectTo"):
|
||
v = sc.get(fld)
|
||
if v and v not in status_keys:
|
||
err(f"scenario.{fld}: «{v}» не объявлен в statuses {sorted(status_keys)}")
|
||
if "scenario" in cfg and not sc.get("control"):
|
||
err("scenario.control: если задан scenario, в нём должно быть одно главное (primary) действие.")
|
||
|
||
# drawer ключи → поля ряда (колонки + любые поля строки)
|
||
dr = cfg.get("drawer", {})
|
||
if dr.get("titleKey") and data_keys and dr["titleKey"] not in data_keys:
|
||
err(f"drawer.titleKey: «{dr['titleKey']}» нет в данных ряда {sorted(data_keys)}")
|
||
for j, f in enumerate(dr.get("fields", [])):
|
||
if f.get("key") and data_keys and f["key"] not in data_keys:
|
||
err(f"drawer.fields[{j}].key: «{f['key']}» нет в данных ряда {sorted(data_keys)}")
|
||
|
||
# chart ключи → реальные колонки/поля ряда (как column.sub/drawer — единый класс проверок)
|
||
ch = cfg.get("chart", {}) or {}
|
||
if ch.get("dateKey") and data_keys and ch["dateKey"] not in data_keys:
|
||
err(f"chart.dateKey: «{ch['dateKey']}» нет в данных ряда {sorted(data_keys)}")
|
||
if ch.get("groupBy") and data_keys and ch["groupBy"] not in data_keys:
|
||
err(f"chart.groupBy: «{ch['groupBy']}» нет в данных ряда {sorted(data_keys)}")
|
||
if ch.get("bucket") == "day" and not ch.get("dateKey"):
|
||
err("chart.bucket=\"day\": требуется chart.dateKey (колонка-дата для раскатки по дням).")
|
||
|
||
# banner.actionNav → существующий индекс nav
|
||
bn = cfg.get("banner", {}) or {}
|
||
nav_len = len(cfg.get("nav", []) or [])
|
||
if bn.get("actionNav") is not None and nav_len and not (0 <= bn["actionNav"] < nav_len):
|
||
err(f"banner.actionNav: индекс {bn['actionNav']} вне диапазона nav (0..{nav_len - 1}).")
|
||
|
||
# nav/onboarding иконки → существуют в спрайте (опечатка иначе рендерит пустую иконку)
|
||
ids = sprite_ids()
|
||
if ids:
|
||
for k, n in enumerate(cfg.get("nav", []) or []):
|
||
if n.get("icon") and n["icon"] not in ids:
|
||
warn(f"nav[{k}].icon: «{n['icon']}» нет в спрайте icons/kt-ai-lucide-sprite.svg")
|
||
for k, o in enumerate(cfg.get("onboarding", []) or []):
|
||
if o.get("icon") and o["icon"] not in ids:
|
||
warn(f"onboarding[{k}].icon: «{o['icon']}» нет в спрайте icons/kt-ai-lucide-sprite.svg")
|
||
|
||
# запрет project-метрик на операционном дашборде
|
||
view = cfg.get("product", {}).get("view", "operational")
|
||
if view == "operational":
|
||
for k, kpi in enumerate(cfg.get("kpis", [])):
|
||
blob = f"{kpi.get('value','')} {kpi.get('label','')}"
|
||
if FORBIDDEN_METRIC.search(blob):
|
||
warn(f"kpis[{k}] «{kpi.get('label','')}»: похоже на ROI/FTE/экономию — это метрика проекта. "
|
||
f"Перенесите в product.view=management или замените на рабочее число (очередь, риск, просрочка).")
|
||
|
||
# debug-язык + проповеднический/мета-копирайт в пользовательских строках
|
||
for p, s in iter_user_strings(cfg):
|
||
if DEBUG_LANG.search(s):
|
||
warn(f"{p}: служебная формулировка генерации в UI («{s[:50]}…»). "
|
||
f"Пользователь видит продуктовый язык, не кухню сборки.")
|
||
if PREACHY_LANG.search(s):
|
||
warn(f"{p}: дисклеймер/нравоучение в UI («{s[:50]}…»). "
|
||
f"Убери — решение показывают контролы (рекомендация + кнопки), а не объяснения про прототип/human-in-the-loop.")
|
||
|
||
# объём данных (только если экран вообще табличный)
|
||
if "table" in cfg:
|
||
n = len(table.get("rows", []))
|
||
# дневной time-series график легитимно требует плотности данных → выше потолок
|
||
daily = (cfg.get("chart", {}) or {}).get("bucket") == "day"
|
||
cap = 60 if daily else 16
|
||
if n < 5:
|
||
warn(f"table.rows: {n} строк — таблица оправдана от 5 записей; добавьте правдоподобных данных (8-12).")
|
||
elif n > cap:
|
||
warn(f"table.rows: {n} строк — для прототипа достаточно 8-12" + (" (или ≤60 для дневного графика)." if daily else "."))
|
||
|
||
# длина статус-лейблов (до 4 слов) — DoD G1
|
||
for key, st in statuses.items():
|
||
if len(st.get("label", "").split()) > 4:
|
||
warn(f"statuses.{key}.label: > 4 слов; длинное объяснение — в drawer.")
|
||
|
||
# DoD G1: заголовки колонок коротко (≤3 слов; деталь — в drawer)
|
||
for c in (table.get("columns", []) if "table" in cfg else []):
|
||
if len(str(c.get("label", "")).split()) > 3:
|
||
warn(f"table.columns «{c.get('key')}».label: > 3 слов; заголовок таблицы — рабочий объект, не описание.")
|
||
|
||
# DoD G1: KPI-полоса тихая — 2-4 главные метрики, не стена чисел
|
||
if len(cfg.get("kpis", []) or []) > 4:
|
||
warn(f"kpis: {len(cfg['kpis'])} метрик — полоса KPI отвечает на 2-4 главных вопроса; остальное в drawer/аналитику.")
|
||
|
||
# DoD G5 / закон Хика: первичные фильтры = workflow-статусы, ≤4 + «Все»
|
||
if len(statuses) > 5:
|
||
warn(f"statuses: {len(statuses)} статусов — таб-фильтр держит ≤4+«Все»; confidence/risk-type/системы не идут в первичные фильтры (Хик).")
|
||
|
||
|
||
def iter_user_strings(cfg):
|
||
"""Пользовательские строки (без ai.fallback — там легитимны слова про прототип)."""
|
||
def walk(node, path):
|
||
if isinstance(node, str):
|
||
yield path, node
|
||
elif isinstance(node, dict):
|
||
for k, v in node.items():
|
||
if path == "$.ai" and k == "fallback":
|
||
continue
|
||
yield from walk(v, f"{path}.{k}")
|
||
elif isinstance(node, list):
|
||
for i, v in enumerate(node):
|
||
yield from walk(v, f"{path}[{i}]")
|
||
yield from walk(cfg, "$")
|
||
|
||
|
||
# Нравоучение про human-in-the-loop — свой же красный флаг из CHECKLIST G4.
|
||
# Решение показывают КОНТРОЛЫ («Согласовать», «Переписать вручную»), а не абзац
|
||
# текста: пользователь и так видит кнопку, а абзац читает как недоверие к себе.
|
||
#
|
||
# Ловим ФРАЗЫ, а не корень «подтвержд». Разница принципиальна: «Тариф подтверждён
|
||
# по SAP» и «ждёт подтверждения мастера с фото» — факты о состоянии, они нужны.
|
||
# Нравоучение — это утверждение ПОЛИТИКИ («ни один договор не уходит без…»),
|
||
# которое ничего не сообщает о конкретной записи.
|
||
MORALIZING = re.compile(
|
||
r"(только после [\w\s]{0,20}подтвержд"
|
||
r"|без подтвержд[\w\s]{0,25}не\s"
|
||
r"|ни один[^.]*без подтвержд|ни одна[^.]*без подтвержд"
|
||
r"|подтверждает только"
|
||
r"|агент ничего не (отправляет|делает)"
|
||
r"|не действует сам|решение (за вами|принимаете вы)"
|
||
r"|проверьте факты|это (лишь )?прототип|данные иллюстративны"
|
||
r"|контроль человека|подтверждает человек)", re.I)
|
||
|
||
|
||
def lint_moralizing(cfg):
|
||
for path, val in iter_user_strings(cfg):
|
||
for sent in re.split(r"(?<=[.!?])\s+", val):
|
||
m = MORALIZING.search(sent)
|
||
if m:
|
||
err(f"{path}: нравоучение про human-in-the-loop — «{sent.strip()[:70]}». "
|
||
f"Решение показывают контролы, а не абзац текста (CHECKLIST G4).")
|
||
|
||
|
||
def check_one(cfg_path, schema, strict):
|
||
"""Проверяет один конфиг. Возвращает True, если он ПРОВАЛИЛСЯ."""
|
||
global ERRORS, WARNINGS, NOTES
|
||
ERRORS, WARNINGS, NOTES = [], [], [] # глобальные списки — обнуляем на каждый файл
|
||
|
||
try:
|
||
cfg = json.load(open(cfg_path, encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError) as e:
|
||
print(f" ✗ ERROR {os.path.basename(cfg_path)}: не читается — {e}")
|
||
return True
|
||
|
||
validate_schema(cfg, schema)
|
||
lint_moralizing(cfg)
|
||
if not ERRORS: # lint только на структурно валидном конфиге
|
||
lint_rules(cfg)
|
||
|
||
name = os.path.basename(cfg_path)
|
||
for e in ERRORS:
|
||
print(f" ✗ ERROR {e}")
|
||
for n in NOTES:
|
||
print(f" ℹ ЗАМЕТКА {n}")
|
||
for w in WARNINGS:
|
||
print(f" ⚠ WARN {w}")
|
||
|
||
fail = bool(ERRORS) or (strict and WARNINGS)
|
||
status = "FAIL" if fail else ("OK с замечаниями" if WARNINGS else "OK")
|
||
print(f"{name}: {status} — {len(ERRORS)} ошибок, {len(WARNINGS)} предупреждений")
|
||
if WARNINGS and not strict:
|
||
print(" → handoff/CI: прогоните с --strict, чтобы предупреждения честности стали блокирующими.")
|
||
return fail
|
||
|
||
|
||
def main():
|
||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||
strict = "--strict" in sys.argv
|
||
if not args:
|
||
print("usage: validate_product.py <config.json | 'glob/**/*.config.json'> [--strict]",
|
||
file=sys.stderr)
|
||
return 2
|
||
|
||
# Глоб раскрываем САМИ: гейт из доков — "app/**/*.config.json" в кавычках,
|
||
# то есть шелл его не раскрывает, и раньше это падало FileNotFoundError
|
||
# на литеральной строке. Кавычки нужны, иначе шелл подставит только первый файл.
|
||
targets, schema_arg = [], None
|
||
for a in args:
|
||
if any(ch in a for ch in "*?[") or os.path.isdir(a):
|
||
pattern = os.path.join(a, "**", "*.config.json") if os.path.isdir(a) else a
|
||
targets.extend(sorted(glob.glob(pattern, recursive=True)))
|
||
elif a.endswith(".schema.json"):
|
||
schema_arg = a
|
||
else:
|
||
targets.append(a)
|
||
|
||
if not targets:
|
||
print(f"Не найдено ни одного конфига по: {' '.join(args)}", file=sys.stderr)
|
||
return 2
|
||
|
||
schema_path = schema_arg or os.path.join(ROOT, "product.schema.json")
|
||
schema = json.load(open(schema_path, encoding="utf-8"))
|
||
|
||
failed = [t for t in targets if check_one(t, schema, strict)]
|
||
|
||
if len(targets) > 1:
|
||
print(f"\nИтого: {len(targets) - len(failed)}/{len(targets)} прошло"
|
||
+ (f", упало: {', '.join(os.path.basename(f) for f in failed)}" if failed else ""))
|
||
return 1 if failed else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|