diff --git a/docs/DESIGN-RATIONALE.md b/docs/DESIGN-RATIONALE.md new file mode 100644 index 00000000..b69d9247 --- /dev/null +++ b/docs/DESIGN-RATIONALE.md @@ -0,0 +1,359 @@ +# DESIGN RATIONALE — продуктовые проектные решения archlint + +Безличный свод продуктовых "почему": почему метрика классифицирована как ERROR/WARNING/INFO, +почему дельта-режим, почему модель именно такая. Каждый пункт — продуктовое решение и +обоснование принципом конституции (см. ROADMAP.md). Это не журнал процесса, а конспект +проектных trade-off'ов для контрибьютора. + +Связанные документы: proof-catalog.md (каталог метрик), ROADMAP.md (фазы и карта), +adr/0002 (модель графа и тиринг). + +--- + +## R1 — единая модель графа + тиринг метрик + +РЕШЕНИЕ: модель повышена до property graph (implements ребром, type-flow), метрики +разбиты на 4 тира (боевой Go / research / музей / порт). Принято как ADR-0002. + +ПОЧЕМУ: +- СКОРОСТЬ + РЕЖИМЫ: research-тир зависал (OOM, десятки секунд). Музей честно отделяет + "заморожено навсегда" от "временно медленно"; быстрый гейт не тащит комбинаторно-взрывное. +- ДОКАЗУЕМОСТЬ: богатая модель (property graph, implements ребром, type-flow) даёт метрикам + видеть больше структуры = больше доказуемости. + +КРИТЕРИЙ СОСТАВА МУЗЕЯ (чтобы не было произвола): метрика остаётся в Тир3 ⟺ выполнено +хотя бы одно — (1) комбинаторно-взрывная / не досчитывается на реальном графе; (2) не +привязывается к доказуемому арх-принципу. Метрика медленная, НО доказывающая принцип, — +кандидат на оптимизацию (sparse), не на музей навсегда. + +## R2 — severity ERROR требует доказанной привязки к принципу + +КОНТЕКСТ: метрики condition_number и channel_capacity выдавали вердикт ERROR на ЗДОРОВОМ +эталоне (archlint сам на себе). Битыми не являются — вопрос в калибровке severity. + +РЕШЕНИЕ (политика): severity=ERROR — утверждение "это ТОЧНО нарушение принципа", оно +БЛОКИРУЕТ гейт. Право на ERROR имеет только метрика с ДОКАЗАННОЙ привязкой к арх-принципу +(обоснование соундности каталога). Метрика без доказательства не блокирует: дефолт +severity = WARNING (сигнал, не блок). + +ПОЧЕМУ: линтер, выдающий ERROR на здоровый эталон (сам на себя) без доказательства, что +число ⟺ нарушение, = ложная тревога = подрыв доверия. На возражение "ваш линтер ругается +сам на себя" нужен железный аргумент, а не порог "от балды". + +## R3 — каркас соундности: однонаправленная импликация + +РЕШЕНИЕ (официальная формулировка обоснования метрики): доказательство принципа = соундная +ОДНОНАПРАВЛЕННАЯ импликация "срабатывание метрики ⟹ реальный дефект". Не биусловие. Соундный +гейт обязан пропускать здоровый код. + +ПОЧЕМУ: уточняет и усиливает R2. Разделение честное: гейт блокирует только соундным, +магнитудная математика остаётся в Тир2 (исследовательская ценность сохранена). + +## R4 — self-эталон как РАЗМЕЧЕННЫЙ оракул ERROR-права + +РЕШЕНИЕ: прогон archlint-сам-на-себе = оракул; метрика получает ERROR ⟺ (а) доказанная +привязка И (б) на РАЗМЕЧЕННОМ self-эталоне нет срабатываний, ПРОТИВОРЕЧАЩИХ разметке. + +УТОЧНЕНИЕ (дыра наивной формы): "self заведомо здоров" недоказуемо — реальный дефект archlint +дал бы ИСТИННЫЙ ERROR на self, и наивный фильтр зарезал бы соундную метрику. Поэтому self — +не булев автомат, а РАЗМЕЧЕННЫЙ оракул (golden expectation: для self-графа зафиксирован +ожидаемый вердикт по каждому принципу). Расхождение метрика-vs-разметка = fail-fast: БЛОКИРУЕТ +ERROR-право до ручного разбора (метрика-самозванец ИЛИ реальный дефект archlint), а не молча +режет метрику. + +ПОЧЕМУ: self — необходимое, но НЕ достаточное условие (защита от overfit к одному графу). +"Линтер не ругается сам на себя" из лозунга становится проверяемым инвариантом. Дёшево, +ловит самозванцев до теории. + +## R5 — двухслойная severity: класс × режим + +РЕШЕНИЕ: активация ERROR-блокировки в боевом гейте требует двух слоёв: severity-КЛАСС +(ERROR-capable) × РЕЖИМ (ДЕЛЬТА от baseline, не абсолют) + прохождение размеченного +self-оракула. До разметки self ERROR-capable метрики стартуют как WARNING. + +ПОЧЕМУ: на легаси с сотнями нарушений абсолютный гейт бесполезен — блокирует всё. Дельта +блокирует НОВЫЕ нарушения (регрессию), пропуская исторический долг. + +## R6 — β₁ (число Бетти) НЕ доказывает "нет циклов"; SCC доказывает + +ФАКТ: betti β₁ и fundamental_group rank(π₁) дали одно число — математически тождественны +(для графа rank(π₁) = β₁ = E − V + C, цикломатическое число). Один инвариант, два имени -> +схлопнуты в один (betti β₁; π₁ убран как алиас). archlint борется с дублями — держать +метрику-дубль в самом archlint иронично против цели. + +РЕШЕНИЕ: принцип "нет циклов" доказывает SCC (Tarjan), β₁ -> дескриптор сложности (INFO/research). +- Контрпример "ромб" (A->B, A->C, B->D, C->D): β₁=1 при НУЛЕ ориентированных циклов; ромб = + здоровая diamond-зависимость. β₁ — неориентированный инвариант, принцип цикличности + ориентирован -> категориальная ошибка маппинга. β₁ честно доказывает другое: удалённость + от дерева = дескриптор СТРУКТУРНОЙ сложности. +- SCC: цикл ⟺ SCC размера>1 или петля. Чистое iff, соундно + полно. ERROR-право законно. + +## R7 — порт цикл-детекции на Tarjan SCC (дыра полноты) + +ФАКТ: боевая цикл-детекция НЕ была Tarjan. Перечисление простых циклов с ограничением длины +(max_length=10) ПРОПУСКАЕТ циклы длиннее 10 (дыра ПОЛНОТЫ: длинная циклическая зависимость = +ложно-зелёный гейт). Альтернативный путь — рекурсивный DFS, тоже не Tarjan. + +РЕШЕНИЕ: порт на Tarjan SCC. Это (1) закрытие дыры полноты (цикл ⟺ SCC>1/петля, без лимита +длины); (2) первая материализация соундной каталожной карточки (SCC) в боевом Go; (3) +соундно + полно: гейт перестаёт пропускать длинные циклы. Верифицировано golden-тестом с +циклом длины 12 (который перечисление с max_length=10 пропускает, Tarjan ловит). + +## R8 — ось паттерн/магнитуда как организующий принцип каталога + +РЕШЕНИЕ (организующая ось): СОУНДНЫЙ ГЕЙТ (ERROR) = качественный запрещённый ПАТТЕРН (наличие +X ⟹ дефект, порог не нужен). ДЕСКРИПТОР (INFO) = количественный ПОРОГ магнитуды (порог +произволен -> нет импликации). Правило ворот: ERROR ⟺ детектор паттерна, не превышение порога. + +ПОЧЕМУ: режет ~90% самозванцев ДО математики. Даёт железный ответ на "почему fan-out<=5 не +блок": 5 произволен (магнитуда); покажите запрещённый паттерн. В режиме дельты магнитуда может +питать РЕГРЕССИОННЫЙ WARNING (рост от baseline), но НИКОГДА ERROR. + +## R9 — слои L объявляются КОНФИГОМ, не выводятся из графа + +РЕШЕНИЕ: объявление слоёв L = КОНФИГ (.archlint layers), НЕ алгоритмический вывод. Уровень A +(SCC модульный) работает без L как floor — всегда доступен. + +ПОЧЕМУ: вывести слои из графа, чтобы их же проверять = циркулярность (выведенное всегда +согласовано). L = intent архитектора, как OCP-intent. Тот же класс intent, что entry-points R +у мёртвого кода. + +## R10 — modularity Q НЕ доказывает слоистость + +РЕШЕНИЕ: modularity Q — INFO-дескриптор, не ERROR. + +ПОЧЕМУ: Q меряет наличие СООБЩЕСТВ (плотных групп), а сообщества ≠ слои. Q направленно-слеп +(неориентированный/симметризованный граф), слоистость ОРИЕНТИРОВАНА. Высокий Q при сломанной +слоистости и наоборот возможны. Та же категориальная ошибка, что β₁ под циклы. + +## R11 — coupling-магнитуда НЕ ERROR; соундный coupling-гейт = паттерны + +РЕШЕНИЕ: Ca/Ce/instability/D/fan — INFO/research, не ERROR (порог произволен). Соундный +coupling-гейт живёт в качественных паттернах (DIP-ребро, слой-back-edge, SCC-цикл), не в магнитуде. + +## R12 — entry-points R для мёртвого кода = КОНФИГ + асимметрия цены ошибки + +РЕШЕНИЕ: R = КОНФИГ (.archlint entrypoints) + правила триггер-фреймворков, НЕ эвристика. Для +библиотеки R = все exported (детерминировано, авто). Дефолт ЩЕДРЫЙ: main() + ВСЕ exported + +init() + Test*-функции. Класс intent как слои L / OCP. + +АСИММЕТРИЯ ЦЕНЫ ОШИБКИ (определяет строгость ворот): у мёртвого кода ложно-мёртвый (можно +удалить ЖИВОЕ) >> ложно-живой (оставили мёртвое). Поэтому мёртвый код = единственная +ERROR-capable метрика с ТРОЙНЫМ замком: (1) R через конфиг, не эвристика; (2) режим дельта; +(3) self-оракул чист. И главное: AUTO-FIX-удаление НЕ автоматичен без подтверждения человека — +гейт лишь СИГНАЛИЗИРУЕТ. Это отличает мёртвый код от SCC (где ложное срабатывание = шум). + +## R13 — DIP демотирован из ERROR в WARNING (self-оракул сработал) + +КОНТЕКСТ: self-прогон DIP дал 15 нарушений, из них 14 — на ЛЕГАЛЬНЫХ DTO-возвратах +(интерфейс, возвращающий data-holder). Это само-фальсификация — ложный ERROR на здоровом self. + +РЕШЕНИЕ: DIP НЕ ERROR-able граф-соундно. Демотация в WARNING-max. + +ПОЧЕМУ: +- "Деталь" = конкретика С ПОВЕДЕНИЕМ; DTO/value-object из интерфейса = словарь абстракции + (данные), не деталь. STRICT (всякий concrete = деталь) доказывает НАДМНОЖЕСТВО ⊋ DIP — + категориальная ошибка как β₁ vs SCC. Дискриминатор DTO/behavioral неформализуем соундно + (магнитуда/эвристика -> не ERROR по оси). "Деталь" семантична, её нет в графе. +- ERROR-КОР НЕ ПОТЕРЯН: злой DIP (абстракция->конкрет с импортным циклом) уже ловит SCC (ERROR). + ERROR-достойные DIP = подмножество циклов. Демотация не теряет ни одного соундного ERROR. +- Whitelist-to-ERROR ОТКЛОНЁН: whitelist-DTO работает только на self -> на чужом репо DTO не + размечены -> ложит. Overfit-ловушка. DIP = WARNING-forever (класс), суждение на человеке. + +ВАЛИДАЦИЯ ПРИНЦИПА: self-прогон на реальном коде поймал натяжку до прода. Двухслойные ворота +(self-оракул как фильтр) реально режут, а не декорация. КАЖДАЯ ERROR-карточка проходит +self-горнило перед промотацией. + +## R14 — SRP подаётся как decomposability, не как "число ответственностей" + +РЕШЕНИЕ: метрика SRP = κ(LCOM4)>=2, WARNING-max, под именем "decomposability / +zero-coupling split detectable", НЕ под именем "SRP". + +ПОЧЕМУ: κ>=2 доказывает ДЕКОМПОЗИРУЕМОСТЬ (тип разбивается на >=2 групп с нулевой связностью), +НЕ "число ответственностей". Контрпример: Vector{x,y,z} с несвязными методами даёт κ>=2 при +одной ответственности. SRP структурно недоказуем (семантика). Не выдавать декомпозируемость за SRP. + +## R15 — LSP: честный ОТКАЗ + +РЕШЕНИЕ: archlint публично заявляет "LSP не доказуем арх-графом, не претендуем". + +ПОЧЕМУ: LSP = поведенческая субтипизация (pre/post/инварианты) — семантика ниже арх-уровня. +Сигнатурная конформность (тень LSP) уже гарантирована компилятором Go при satisfaction +интерфейса -> graph-метрика соундного не добавляет (анти-редундантность: не дублировать уже +гарантированное). Честное "нет" дороже натянутого "да" — кредибельность стержня растёт от границы. + +## R16 — дубли и overengineering: в основном дескрипторы, ERROR нет + +РЕШЕНИЕ: +- Дубли: motif z-score / симметрия — INFO + регрессия-WARNING (магнитуды); структурный клон — + WARNING-max. ERROR нет. +- Overengineering: single-impl — WARNING + whitelist; depth/fan-out/слой — INFO + регрессия. ERROR нет. + +ПОЧЕМУ: изоморфизм ⟹ сходство, НЕ ⟹ вредная копипаста (легитимная повторяемость есть: два +CRUD-хендлера одной формы). Реальная token/AST-копипаста — ниже арх-уровня, намеренно исключена. +single-impl необходимо-не-достаточно: легитимные случаи (DI-шов для моков, плановое расширение, +plugin-seam). overengineering самый intent-зависимый — "доказать overengineering" было бы +слабейшей претензией. Один чистый паттерн (мёртвый код) -> гейт; масса магнитуд -> дескрипторы. + +## R17 — мёртвый код промотирован в ERROR (первый open-world) + +КОНТЕКСТ: после закрытия двух пробелов entry-set R (регистрация через package-level var у +cobra-фреймворка; сканирование _test.go) self-прогон дал 89 -> 5 мёртвых, 0 false-dead, 5 +реальных находок. Горнило чисто. + +РЕШЕНИЕ: dead-code = ERROR (open-world условно-соундный). ПЕРВАЯ метрика, прошедшая полное горнило. + +КЛАСС И КОМПЕНСАЦИИ (следствие open-world условности, не осторожность): +- (1) ДЕЛЬТА-режим (новый мёртвый vs baseline, не абсолют); +- (2) ТРОЙНОЙ ЗАМОК (R через общие механизмы не эвристика, дельта, чистое self-горнило); +- (3) АВТО-УДАЛЕНИЕ всегда human-in-loop (ERROR-сигнал ≠ авто-действие). + +ДВЕ ВАЛИДАЦИИ МОДЕЛИ НА РЕАЛЬНЫХ ДАННЫХ: +- qname-идентичность модели ТОЧНЕЕ грепа: коллизия одноимённых функций (одна мёртва в одном + пакете, другая жива в другом) развелась по квалификации пакета АВТОМАТИЧЕСКИ; name-греп + потребовал ручного разведения. Модель (qname-узлы) доказала точность на реальном кейсе. +- граница test-only эмпирична: функция, реально вызываемая тестом, ожила; функции в *_test.go + с нулём вызовов даже из тестов остались мёртвыми. Развод "test-only-smell" (достижим из + теста = живой) vs "мёртвый тест-хелпер" (недостижим даже из теста = мёртв) на данных. + +ЭМПИРИЧЕСКАЯ ВАЛИДАЦИЯ УДАЛЕНИЕМ: после удаления 5 мёртвых build зелёный, vet чистый, тесты ok, +0 "imported and not used". Если бы хоть одна была живой, сломалось бы. Не сломалось = метрика +дала верное. + +## R18 — references как СИНТАКСИЧЕСКИЙ value-use (предусловие dead-code) + +РЕШЕНИЕ: references материализуются как синтаксический value-use (символ функции/метода в +не-call позиции: address-taken/assigned/passed) БЕЗ резолва цели; резолв по имени = +over-approximation (символ Method в не-call позиции -> ребро на все одноимённые методы/функции). + +ПОЧЕМУ: реальные callback'и = метод-значения; если references требует резолва типа переменной +(которого в модели нет) -> references пуст -> callback-функция без входящего ребра -> reach +флагует МЁРТВОЙ -> ложно-мёртвый (дорогая destruction-сторона). Синтаксический over-approx (не +var-type inference, остаётся на арх-уровне) даёт callback входящее ребро -> destruction-безопасно. +var-type inference (value-flow на уровне переменных) намеренно исключён — не заходим ниже арх-уровня. + +## R19 — реализация модели: implements ребром, over-approximation в дешёвую сторону + +РЕШЕНИЕ (Фаза 1): материализовать implements РЕБРОМ из реального method-set (criterion +requiredMethods(I) ⊆ providedMethods(T)) + рекурсивный embeds-промоушен методов. Расширить +usesType на сигнатурные (param) и field type-references. reach ОБЯЗАН раскрывать +implements-dispatch (interface-method достижим ⟹ все реализации via implements достижимы). + +НАХОДКА: настоящего implements в Go-анализаторе НЕ было — был хак "struct с полем-интерфейсом", +НЕ method-set. Значит прежние метрики на implements стояли на ФЕЙКОВОМ факте (несоундны). Убрать +фейковый факт из-под метрик — ровно то, за что борется archlint (метрика на реальном факте, не +на хаке). Доказуемость требует ревизии самих фактов модели. + +ПРИНЦИП НАПРАВЛЕНИЯ ОШИБКИ: где факт неточен (implements name-only без сигнатур; нерезолвленные +value-refs), приближать в ДЕШЁВУЮ сторону (over-report -> ложно-живой, не ложно-мёртвый; +завышенный счёт реализаций -> ложно-НЕ-сработает WARNING). over-approximation безопасна, пока +ни одна карточка не делает "T ТОЧНО реализует I по сигнатуре" триггером нарушения. + +## R20 — DIP-ребро навешивается от узла-интерфейса (гранулярность отчёта) + +РЕШЕНИЕ: DIP-ребро абстракция->деталь навешивается от узла-ИНТЕРФЕЙСА (не от узла-метода). + +ПОЧЕМУ: для DIP-ВЕРДИКТА эквивалентно — ребро абстракция->деталь существует в обоих случаях, +соундность держится на WHETHER (есть нарушение), не WHERE (какой метод). Метод-гранулярность +ОТЧЁТА доступна позже без переделки. Гранулярность отчёта = продуктовое решение, не вопрос соундности. + +## R21 — ISP: ERROR-able с 2 синтаксическими guard'ами; ось предсказала исход + +РЕШЕНИЕ: ISP ERROR-able (3-я ERROR после SCC, dead-code) — но ТОЛЬКО с двумя синтаксическими +guard'ами. Без них false-fire на кондуитах -> WARNING (как DIP). + +СОУНДНАЯ ФОРМА: ISP-ERROR ⟺ строгое подмножество прямых вызовов И guard1 (i НИКОГДА не в +value-позиции = не форвардится, иначе no-verdict) И guard2 (клиент-метод НЕ реализует интерфейс += сигнатура не диктуется контрактом, иначе suppress/WARNING). Свой интерфейс + свой клиент = +ERROR; внешний (io.*) -> WARNING (фикс "сузь параметр" не всегда доступен). + +ВАЛИДАЦИЯ ОСИ (центральное): ISP имеет ТОТ ЖЕ конфаунд, что демотировал DIP — легальное строгое +подмножество (КОНДУИТ/форвардинг). НО DIP-дискриминатор (DTO vs behavioral) СЕМАНТИЧЕСКИЙ -> +ось запретила ERROR; ISP-дискриминатор (форвардится vs нет = i в value-позиции) СИНТАКСИЧЕСКИЙ +-> ось разрешает ERROR. ТА ЖЕ ОСЬ, ОБРАТНЫЙ ИСХОД. Ось паттерн/магнитуда не классифицирует +постфактум, а ПРЕДСКАЗЫВАЕТ, кто выживет. + +КЛАСС: CLOSED-WORLD НА РАЗРЕШИМОМ ПОДДОМЕНЕ. Не покоится на недоказуемом допущении полноты +(как dead-code/R) — ВОЗДЕРЖИВАЕТСЯ где не может решить (форвард / не-param-typed / контракт-связан). +cost_of_false_fire = IRRITATION (ложный ISP = глянул, подавил, код не разрушен). НЕ нужен тройной +замок/human-in-loop (destruction-специфичны dead-code), только guard воздержания + дельта для легаси. + +## R22 — реализация ISP без go/types (приоритет скорости) + +РАЗВИЛКА: +- (A) go/types-апгрейд: точно (ISP + signature-точный implements + точные calls), но дорогая + интеграция + архитектурный сдвиг analyzer + РИСК СКОРОСТИ (type-checking медленнее лайтового AST). +- (B) MVP param-typed receiver: i:I через параметр сигнатуры (func Use(i I){i.Foo()}) -> i.Foo() = + I.Foo. Переиспользует param-типы, доминирующий ISP-кейс, over-approx-безопасен. + +РЕШЕНИЕ: путь B. + +ПОЧЕМУ: +- СКОРОСТЬ (конституция #3): go/types риск тормоза быстрого гейта — жёсткий принцип. +- Соундность НЕ требует go/types — over-approx работает (dead-code прошёл горнило на name-only + implements). go/types даёт ТОЧНОСТЬ (полноту), не соундность, а точность не самоцель. +- go/types -> отложенный стратегический вопрос, ЕСЛИ всплывёт потребность в точности (напр. + метрика с signature-точным implements). Не сейчас. + +## R23 — дельта-режим как соундность-СОХРАНЯЮЩИЙ слой активации + +ТЕЗИС: дельта = соундность-СОХРАНЯЮЩИЙ слой АКТИВАЦИИ над уже-соундными паттернами. Блокирует +на NEW ⊆ всех-обнаруженных (все соундны) -> НЕ может сделать соундный гейт несоундным. Меняет: +(1) релаксирует ПОЛНОТУ (легаси -> telemetry); (2) вводит ОДНУ зависимость — идентичность +паттерна между прогонами. + +ИДЕНТИЧНОСТЬ = СТРОГИЙ qname-key (dead-узел: qname; back-edge: пара qname; SCC: отсортированное +множество member-qname/хеш; ISP: пара qname). RENAME -> ложный-NEW = ПРИЕМЛЕМО (irritation, +defensible). НЕ rename-tracking/fuzzy: эвристика/магнитудный матч -> ось ЗАПРЕЩАЕТ соундный гейт +на нечётком матче. Точный qname, точка. (Применение оси к инфраструктуре.) + +FAIL-SAFE = над-блокировать, не под-блокировать. Ложный-NEW (irritation) << ложный-MISSING +(проскок регрессии = провал гейта по назначению). Строгая идентичность смещает ошибки в ложный-NEW. + +СОУНДНОСТЬ ПО КЛАССАМ: closed-world (SCC)/ISP — дельта релаксирует полноту, NEW реален, ложный-NEW = +irritation. open-world (dead-code) — дельта ОБЯЗАТЕЛЬНА (компенсация), но ложный-NEW может быть +false-dead -> human-in-loop на удалении остаётся. + +ГОРНИЛО ДЕЛЬТА-ИНФРЫ (критерий чистого прогона): +- ЯДРО: baseline на self -> повторный прогон ТОГО ЖЕ кода -> дельта ПУСТА (0 ложных-NEW). +- +1 ДЕТЕРМИНИЗМ: два baseline одного кода БАЙТ-идентичны (сортировка, стабильные ключи, нет + map-iteration недетерминизма). Иначе спонтанные NEW -> ядро ложно-красное. +- +2 NO-BASELINE -> NO-BLOCK: нет baseline-файла -> degrade telemetry, НЕ блок. Иначе первый + прогон = absolute-режим блокирует ВСЁ. +- +3 POSITIVE CONTROL: внести 1 новый дефект -> дельта = РОВНО он; откатить -> пуста. +- +4 RENAME-CASE: переименование -> ожидаемо ложный-NEW (документировать как принятое, не баг). +- +5 ПО КЛАССАМ: NEW SCC/ISP -> hard-block; NEW dead-code -> блок-сигнал + удаление human-in-loop. + +ВЕРИФИКАЦИЯ ПО КОДУ: детерминизм (дедуп + sort + json сортирует ключи -> байт-идентичность), +no-baseline->no-block (решение о блоке в слое маршрутизации, не в вычислении дельты — чистое +разделение). Тезис "дельта = соундность-сохраняющая активация" материализован в архитектуре: +соундность в детекторе, активация в слое маршрутизации. + +ЛАТЕНТНАЯ ХРУПКОСТЬ (backlog): идентичность SCC/layer-fingerprint завязана на display-строку +сообщения. Сейчас детерминирована, но СЦЕПЛЕНА с презентацией: будущее изменение формата +сообщения молча сменит fingerprint всех циклов -> baseline инвалидируется -> mass-false-block. +Развязать fingerprint от display-строки -> структурное поле (отсортированный member-qname). +Низкий приоритет, дёшево заложить. + +## R24 — семантический сдвиг scan: ERROR-паттерны с count-порога на дельту + +РЕШЕНИЕ: ERROR-паттерны переведены с абсолютного count-порога на ДЕЛЬТУ. Следствие: layer/cycle +БЕЗ baseline = аудит (раньше always-block/count). count-порог сохранён для не-ERROR +(WARNING/магнитуды). + +MIGRATION-нюанс: baseline-first — закрытые SCC релаксируют полноту (без baseline не блокируют +абсолютно), нужен снимок baseline -> commit для активации блокировки нового. Соундность цела +(дельта релаксирует полноту, не соундность). dead-code ВПЕРВЫЕ в scan: NEW dead -> блок-сигнал; +удаление human-in-loop (гейт не удаляет). Реестр generic: новые ERROR-метрики (напр. ISP) +подхватываются дельта-гейтом автоматически после порта. + +## R25 — целевой язык = Go + +РЕШЕНИЕ: целевой язык реализации archlint = Go (не Rust). + +СУДЬБА ТРАКТОВ: +- Go = целевой/боевой. Каталог доказуемости материализуется здесь. Все Фазы 1/3 — Go. +- Rust (archlint-rs): rewrite-направление ЗАМОРОЖЕНО. Существующий код НЕ удаляется (был боевой + для демо, удаление необратимо) — статус "заморожен, не цель, не развиваем". watch-loop гонит + Go-бинарь; Rust в петле НЕ участвует -> полная заморозка, ничего активного не зависит. +- Python validator = музей (Тир3). diff --git a/docs/ECOSYSTEM.md b/docs/ECOSYSTEM.md new file mode 100644 index 00000000..2ea2a078 --- /dev/null +++ b/docs/ECOSYSTEM.md @@ -0,0 +1,111 @@ +# Карта экосистемы archlint: тракты реализации и их отношение + +- Статус: Accepted +- Опора: ROADMAP.md (раздел ЦЕЛЕВАЯ РЕАЛИЗАЦИЯ), proof-catalog.md, adr/0002-... +- Уровень: ЭКОСИСТЕМНЫЙ (тракты + отношение). Намеренно БЕЗ деталей модели + графа и состава метрик — они меняются ежедневно в Фазе 3; смотри ADR-0002 + (модель) и proof-catalog.md (метрики), не этот документ. + +## 1. Зачем эта карта + +archlint — полиглот: в одном репозитории сосуществуют три параллельные +реализации на разных языках плюс внешняя математическая зависимость. Без явной +карты легко принять один тракт за другой (Rust-пласт можно ошибочно счесть +актуальным направлением, а Python validator — живым гейтом). Карта фиксирует: КТО +целевой, КТО заморожен, КТО музей, и КАК они относятся друг к другу и к внешним +каналам (archmotif, MCP, watch-loop). + +Целевой язык = Go. Rust-rewrite заморожен. Карта отражает эту цель. + +## 2. Три тракта реализации + +### Go-тракт — ЦЕЛЕВОЙ / боевой + +- Где: `internal/` (model, analyzer, mcp, archmotifbridge, watcher, optimizer) + + `cmd/` (точки входа). Собранный бинарь: `bin/archlint`. +- Роль: ЕДИНСТВЕННЫЙ боевой тракт. Здесь материализуется каталог доказуемости + (proof-catalog.md): формальное определение принципа -> метрика -> обоснование + соундности. Все Фазы 1/3 идут на Go/gonum. +- Чем считает: структурные метрики боевого гейта (Тир1) — чистый Go на gonum, без + внешних процессов. +- Это цель развития: новые метрики, порты структурных метрик из Python, вся + материализация принципов — сюда. + +### Rust-тракт (archlint-rs) — ЗАМОРОЖЕН + +- Где: `archlint-rs/src/` (analyzer.rs, session.rs, language_analyzer.rs, + config.rs, server.rs(axum), diagram.rs, fix.rs, costlint/perflint/seclint/ + promptlint.rs). Граф = petgraph. Правила = конфиг `.archlint.yaml` (per-rule). +- Статус: rewrite-направление ЗАМОРОЖЕНО. Был боевым для демо — это история, не цель. +- Что с ним: НЕ удаляем (рабочий код, ценность как прецедент и для демо), но НЕ + развиваем и НЕ трогаем. Новой работы здесь нет. В карту внесён, чтобы его не + принимали за актуальное направление. + +### Python-тракт (validator/) — МУЗЕЙ + +- Где: `validator/` (NetworkX). +- Роль: Тир3 "музей" из ADR-0002 — комбинаторно-взрывные / NP-hard метрики, + которые на реальных графах не досчитываются. ВНЕ боевого гейта. +- Режим: ручной запуск по требованию, не в горячем пути. Исторический источник + структурных метрик, которые портируются в Go-тракт (после порта живут в Тир1). + +## 3. Внешняя зависимость: archmotif + +- Что: внешняя библиотека матядра. Даёт research-математику на Go/gonum (lambda2, + modularity, motif z-score, curvature, quotient-схлопывание, спектр). +- Граница: Вариант B' из ADR-0002 — in-process Go-import через тонкий публичный + export-слой в форке. Потребляется Go-трактом через `archmotifbridge/`. +- Принцип: archmotif НЕ вендорится и НЕ копируется внутрь archlint — это + единственный источник research-математики (гигиена зависимостей). archlint + отдаёт граф и получает числа. +- Что обслуживает: Тир2 (research) — спектральное/алгебраическое, по требованию, + вне боевого гейта. + +## 4. Боевые каналы Go-тракта + +### MCP-сервер (internal/mcp) + +- archlint выставляет себя как MCP-сервер. Это боевой канал гейта: агент-потребитель + получает вердикты метрик (нарушения архитектуры) через MCP-инструменты. +- Режим: боевой гейт качества в агентском loop. + +### watch-loop (агентский self-fix) + +- Цепочка: watch-скрипт запускает `archlint watch` + `archlint scan` + + `archlint callgraph` -> при нарушении пишет нарушения в файл -> агент читает файл и + исправляет; при исправлении файл очищается. +- ТРАКТ: Go. Скрипт гонит бинарь `bin/archlint` (вызовы watch/scan/callgraph). + Rust/Python в этой петле НЕ участвуют. +- Режим: непрерывный watch-гейт с автоматическим self-fix агентом (human-in-loop + по необходимости). + +## 5. Отношение трактов: что за какой режим отвечает + +| Режим | Тракт / канал | Назначение | +|-------|---------------|------------| +| Боевой гейт (структурный) | Go (bin/archlint: scan/watch) + MCP | вердикты качества в агентском loop, latency-критично | +| Непрерывный watch + self-fix | Go (watch-loop) -> файл нарушений -> агент-фиксер | автокоррекция нарушений на лету | +| Research (спектр/алгебра) | Go -> archmotifbridge -> archmotif | Тир2, по требованию, вне гейта | +| Музей (комбинаторно-взрывное) | Python (validator/, NetworkX) | Тир3, ручной запуск, источник портов в Go | +| Заморожено | Rust (archlint-rs) | не цель, не трогаем, прецедент/история | + +Поток потребления (боевой путь): + + исходный код (Go/Rust/TS) + | + v + Go-тракт archlint: строит модель графа G (ADR-0002) + | + +--> Тир1 структурные метрики (gonum, in-process) --> вердикт + | | + +--> Тир2 research: archmotifbridge -> archmotif | + | (по требованию, не в гейте) | + v v + каналы выдачи: MCP-сервер (агенту) / watch-loop (файл нарушений -> агент-фиксер) + +Python-музей (Тир3) и Rust (заморожен) в боевой поток НЕ входят. + +## 6. Что НЕ входит в эту карту + +- Детали модели графа G и состав/пороги метрик — живут в ADR-0002 и + proof-catalog.md (текут в Фазе 3, здесь не дублируются). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 00000000..0ddd3383 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,134 @@ +# ROADMAP archlint — путь к математической доказуемости архитектурных принципов + +Этот файл — продуктовый roadmap к ЦЕЛИ: путь к стержню продукта, не список фич. + +## ЦЕЛЬ (миссия) + +Владелец архитектуры: инструмент (archlint) + дисциплина, удерживающие архитектуру +в рамках поддерживаемости, развиваемости, читаемости и индустриальных принципов +разработки — в первую очередь для АГЕНТСКОЙ разработки (агенты пишут код -> archlint +держит архитектуру без дублей, копипасты, усложнений, мёртвого кода). + +## КОНСТИТУЦИЯ (приоритет принципов) + +1. PRIVACY (жёсткий) — никакая личная информация не попадает в репозиторий archlint. +2. SECURITY — код archlint безопасен. +3. СКОРОСТЬ + РЕЖИМЫ — быстрый гейт (агентский loop) не имеет права тормозить; отдельно режим полного аудита. +4. СТЕРЖЕНЬ — МАТЕМАТИЧЕСКАЯ ДОКАЗУЕМОСТЬ — каждый декларируемый принцип доказуем; + метрика = ДОКАЗАТЕЛЬСТВО соответствия принципу, а не просто число. + +Мотив: сообщество придирчиво. На каждое возражение — железный аргумент: +математически доказуемая метрика под каждым принципом. Доказуемость > красота > количество фич. + +## ЦЕЛЕВАЯ РЕАЛИЗАЦИЯ +archlint — полиглот, но ЦЕЛЕВОЙ язык = Go. +- Go (internal/, model/, analyzer/, mcp/, archmotifbridge/) = ЦЕЛЕВОЙ/боевой. Каталог + доказуемости материализуется ЗДЕСЬ. Все Фазы 1/3 — Go/gonum. +- Rust (archlint-rs) = rewrite-направление ЗАМОРОЖЕНО. Существующее не удаляем + (был боевой для демо). Статус "заморожен, не цель". +- Python (validator/ NetworkX) = музей (Тир3). + +## ВИДЕНИЕ ДОКАЗУЕМОСТИ (целевое состояние стержня) + +Каждый принцип, который archlint декларирует, имеет ТРИ артефакта: + +1. ФОРМАЛЬНОЕ ОПРЕДЕЛЕНИЕ принципа на графе G = (V, E, λ, μ) (см. ADR-0002). +2. МЕТРИКА-ДОКАЗАТЕЛЬСТВО — функция над G, чьё значение свидетельствует о соответствии/нарушении. +3. ОБОСНОВАНИЕ СОУНДНОСТИ — доказанная ОДНОНАПРАВЛЕННАЯ импликация "срабатывание метрики + ⟹ реальный дефект" (не биусловие). Соундный гейт обязан пропускать здоровый код; + контрпример (срабатывание на здоровом образце) лишает метрику гейт-права. + +ОСЬ ПАТТЕРН/МАГНИТУДА (организующая): ERROR ⟺ детектор качественного запрещённого +ПАТТЕРНА (наличие X ⟹ дефект, порог не нужен). Количественная МАГНИТУДА (произвольный порог) += дескриптор INFO (абсолют) / WARNING (регрессия-дельта), НИКОГДА ERROR. Режет ~90% самозванцев. + +ВОРОТА ДОКАЗУЕМОСТИ (право метрики на severity=ERROR) — метрика блокирует гейт ⟺ + (а) детектор паттерна с обоснованием соундности (соундная привязка, ось выше) И + (б) на РАЗМЕЧЕННОМ self-эталоне (archlint-сам-на-себе, golden expectation per принцип) + нет срабатываний, противоречащих разметке; расхождение -> ручной разбор, не молчаливое ERROR. +Метрика без (а): дефолт severity = WARNING/INFO (сигнал, не блок). Метрика, не +привязываемая к доказуемому принципу, едет в Тир2/research или музей (Тир3), а не тащится +мёртвым потенциалом в гейте (иначе archlint сам нарушает то, с чем борется). + +## КАРТА ПРИНЦИП -> ДОКАЗУЕМАЯ МЕТРИКА + +| Принцип | Формальное определение на G (эскиз) | Метрика-доказательство | Реализация | +|---|---|---|---| +| ISP | Клиент юзает строгое подмножество методов интерфейса | usage-subset S ⊊ methods(I), близко к iff; ERROR-capable | gonum (Тир1) | +| DIP | Абстракция ссылается на деталь | ребро interface->конкретный тип; WARNING. D=|A+I−1| = INFO | gonum (Тир1) | +| SRP -> decomposability | Тип разбивается на >=2 групп с нулевой связностью | κ(LCOM4)>=2; WARNING-max, НЕ "число ответственностей" | gonum (Тир1) | +| OCP | Изменены рёбра предсуществующих узлов при расширении | дельта рёбер V_old; BASELINE-условная (нужен intent) | gonum + diff | +| LSP | — (ОТКАЗ) | не доказуем на арх-графе (поведенческая семантика ниже уровня) | — | +| Слоистость A (floor, без L) | SCC>1 среди модулей = цикл зависимостей | Tarjan SCC модульный; ERROR-capable, ПАТТЕРН | gonum (Тир1) | +| Слоистость B (нужна декларация L) | межслойный back-edge против порядка L | back-edges в G/L; ERROR-capable при L, ПАТТЕРН | gonum (Тир1) | +| Модульность (НЕ слоистость) | плотные сообщества | modularity Q (Newman); INFO-дескриптор, МАГНИТУДА | archmotif (Тир2) | +| Связанность (coupling) | магнитуда связей | Ca/Ce/instability/D/fan — INFO(абс)/WARNING(регрессия), НЕ ERROR | gonum (Тир2) | +| Связность (cohesion) | = decomposability (κ LCOM4), не дублируется | см. SRP->decomposability | gonum (Тир1) | +| Нет дублей/копипасты | z-score/симметрия = магнитуда; структурный клон = паттерн (слабый) | z-score/симметрия INFO+регрессия; клон WARNING-max; ERROR нет | archmotif (Тир2) | +| Нет циклических зависимостей | Цикл ⟺ SCC размера>1 или петля (ориентированный) | Tarjan SCC — чистое iff, ERROR-право | gonum (Тир1) | +| Структурная сложность (примыкает к overengineering, НЕ доказано) | Удалённость от дерева E−V+C | β₁ (Бетти) — дескриптор, severity INFO, research | gonum/archmotif (Тир2) | +| Нет мёртвого кода | Узел недостижим от entry points R (паттерн) | reach(R); ERROR-capable при полном R (КОНФИГ), ТРОЙНОЙ замок, асимметрия цены | gonum (Тир1) | +| Нет overengineering | single-impl=паттерн(слабый), depth/fan-out=магнитуда | single-impl WARNING+whitelist; depth/fan-out INFO+регрессия; ERROR нет | gonum (Тир1/2) | +| Регрессия архитектуры | появление запрещённого паттерна в new-не-baseline | pattern-delta + рост числа компонент = ERROR-capable; d_spec = INFO-скрин/роутер (слепо к знаку) | gonum + archmotif (Тир2) | + +Таблица — рамка приоритизации, НЕ финал. Формальные определения и обоснования соундности +детализированы в proof-catalog.md. + +## ТИРИНГ + +- Тир1 (fast/slow) — боевой гейт, чистый Go/gonum. Latency-критично. +- Тир2 (research) — спектральное/алгебраическое через archmotif как зависимость, по требованию. +- Тир3 (deprecated-музей) — замороженные NP-hard/комбинаторно-взрывные; вырезаны из гейта, + запуск руками. Список зафиксирован факт-листом профилирования: 23 метрики. +- Тир4 — целевое: структурное Python -> Go (после порта живёт в Тир1). + +## ФАЗЫ + +### Фаза 0 — PRIVACY + SECURITY (P0, постоянно) +- Принцип 1, 2. Гарантировать: в репо archlint нет личных данных; код безопасен. +- Постоянный guard при каждом коммите/публикации. Блокирует любой push наружу. +- Статус: действует как инвариант (не разовая задача). + +### Фаза 1 — модель графа (фундамент скорости И доказуемости) +- Повысить `internal/model` до property graph; материализовать `implements` ребром; + добавить type-flow (returns/usesType), control-flow виды; GraphML. +- Зачем первой: без богатой модели метрики не видят структуру -> не могут доказывать. +- Статус: реализована. + +### Фаза 2 — каталог доказуемости (содержательный стержень) +- Для каждого принципа выработать 3 артефакта (определение / метрика / обоснование). +- Старт: SOLID + слоистость + связность (самые востребованные возражениями). +- Статус: первый проход каталога закрыт (см. proof-catalog.md). + +### Фаза 3 — порт Тир4 -> Тир1 на Go через "ворота доказуемости" +- ~86 структурных метрик Python -> Go/gonum. Каждая метрика при порте проходит ворота: + привязка к принципу из каталога (Фаза 2) + golden-тест против Python. +- Метрика без привязки -> музей/выброс, не порт. Скорость и доказуемость вместе. +- Статус: ERROR-детекторы материализованы (SCC/циклы, слоистость-A, dead-code; DIP=WARNING; ISP в работе). + +### Фаза 4 — research Тир2 через archmotif +- Спектр/modularity/motif/симметрия через export-API форка (ADR-0002, вариант B'). +- Доказательства высшего порядка: симметрия=дубли, Q=слоистость, спектр.расстояние=регрессия. + +### Фаза 5 — музей + регрессионный гейт +- Тир3 (23 метрики) заморожен, исключён из автогейта. +- Спектральное расстояние от baseline -> метрика регрессии в боевой гейт (дельта, не абсолют). +- Дельта-инфраструктура (baseline-снапшот + дельта паттернов) -> боевая активация ВСЕХ ERROR + в дельта-режиме. +- Статус: дельта-инфра реализована и верифицирована; боевой дельта-гейт активен. + +## КАТАЛОГ ДОКАЗУЕМОСТИ — СВОДКА (2 оси) +ERROR-capable (паттерны, проходят self-горнило перед промотацией): SCC-циклы [реализован], + слоистость A/B [= SCC, реализован], ISP usage-subset [на проверке self], мёртвый код + (cost=destruction, тройной замок), регрессия pattern-delta, рост числа компонент связности. +WARNING-max: SRP -> decomposability, структурный клон, single-impl, + DIP [демотирован из ERROR: "деталь" семантична, не в графе; ERROR-кор держит SCC-цикл]. +INFO/research (магнитуды, + регрессия-WARNING в дельте): β₁, Q, coupling Ca/Ce/I/D/fan, + condition_number, channel_capacity, motif z-score, симметрия, depth, d_spec (global-скрин/роутер). +ОТКАЗ (не доказуемо на арх-уровне): LSP. +BASELINE-условные: OCP. +cost_of_false_fire: destruction (тройной замок + human) — мёртвый код; остальные irritation. + +## ПРОЕКТНЫЕ РЕШЕНИЯ +См. `DESIGN-RATIONALE.md` — безличный свод продуктовых "почему" (почему метрика X = ERROR/WARNING, +почему дельта-режим, почему модель такая). diff --git a/docs/adr/0001-archmotif-metrics-integration.md b/docs/adr/0001-archmotif-metrics-integration.md new file mode 100644 index 00000000..dd9da4e9 --- /dev/null +++ b/docs/adr/0001-archmotif-metrics-integration.md @@ -0,0 +1,239 @@ +# ADR-0001: Интеграция archmotif как поставщика архитектурных метрик в archlint + +- Статус: Proposed (уточнён ADR-0002) +- ПРИМЕЧАНИЕ: механизм этого ADR (публичный export-пакет в форке archmotif + + in-process Go-import) принят ADR-0002 как основной путь (Вариант B'). ADR-0002 + расширяет рамку (единая модель + тиринг метрик + стратегия миграции Python->Go) + и добавляет fallback через GraphML/публичный CLI. Концепция adapter и mapping + model.Graph -> словарь archmotif из этого ADR переиспользуется в ADR-0002 (Этап 1). + +## Контекст + +archlint строит доменную модель архитектуры (`internal/model.Graph`: компоненты +package/type/function/method + рёбра contains/import/calls/uses/embeds) для Go/Rust/TS +и проверяет её на нарушения. archmotif — отдельный движок, который умеет считать +графовые метрики качества архитектуры (modularity Q, motif redundancy, spectral gap, +local symmetry, cycle rank, instability) и детектировать аномалии поверх них. + +Цель: archlint переиспользует расчётный аппарат archmotif вместо того, чтобы +реализовывать modularity/anomaly-детекцию заново. Подход: adapter-паттерн через +go-import (не CLI, не GraphML-файлы, не сетевой вызов). + +## Ключевая находка (определяет всю архитектуру) + +Форк archmotif УЖЕ имеет публичный вход `pkg/archmotifimport`: + +- `archmotifimport.NewBuilder() *Builder` строит граф императивно; +- `type Graph = mgraph.Graph` — это ALIAS на `internal/graph.Graph`. + +Расчётный аппарат (metrics/anomalies) лежит в `internal/` и наружу НЕ выведен. Go +запрещает чужому модулю импортировать `internal/`, НО `pkg/` того же модуля archmotif +звать свой `internal/` имеет право. Значит мост строится одним новым пакетом В ФОРКЕ — +`pkg/archmotifmetrics` — тонкой обёрткой. archlint импортирует только `pkg/*`, никакой +рефлексии, сериализации или GraphML. + +Поток данных: + +``` +archlint model.Graph + --[adapter: mapping]--> archmotifimport.Builder + --Build()--> *archmotifimport.Graph (== *internal/graph.Graph) + --ComputeMetrics()--> archmotifmetrics.Metrics { Modularity, Anomalies, ... } +``` + +## Решение + +### 1. Новый пакет в форке: `pkg/archmotifmetrics` + +Тонкая обёртка над `internal/metrics` и `internal/anomalies`. EXPORTED-точки, которые +она оборачивает (сигнатуры точные): + +`internal/metrics` (alias mgraph = internal/graph): +- `metrics.Run(g *mgraph.Graph, names []string) metrics.Result` + names=nil/[] -> все зарегистрированные метрики; иначе выбор по Name(). +- `metrics.Result{ Records []Record; Errors []MetricError; Ran []string }` +- `metrics.Record{ Metric string; Scope Scope; Target string; Value float64; Details map[string]any }` + - `ScopeGraph` -> один Value на весь граф (modularity Q, spectral gap живут тут, Target==""). + - `ScopeRegion`/`ScopeNode`/`ScopeEdge` -> Target адресует subject. +- Имена метрик (через init-регистрацию): `modularity`, `motif_redundancy`, + `spectral_gap`, `local_symmetry`, `cycle_rank`, `instability_matrix`, + `layer_mask`, `cycle_matrix`, `zero`. +- `metrics.Names() []string` — перечислить доступные. + +`internal/anomalies`: +- `anomalies.Run(g *mgraph.Graph, records []metrics.Record, names []string) anomalies.Result` + records берутся из `metrics.Result.Records` шага выше. +- `anomalies.Result{ Anomalies []Anomaly; Errors []DetectorError; Ran []string }` +- `anomalies.Anomaly{ Metric, Detector string; Score float64; Region Region; Reason Reason; SourceRecord SourceRecord }` + - `Region{ Kind string; Members []string; PrimaryID string; Files []FileRef }` + - `Reason{ Code, Message string; Details map[string]any }` +- `anomalies.Names() []string`. + +Публичный контракт обёртки (предлагаемый): + +```go +package archmotifmetrics + +import ( + "context" + "github.com/kgatilin/archmotif/pkg/archmotifimport" // см. раздел про module path +) + +// Metrics — плоский результат для потребителя. НЕ протекают internal-типы: +// обёртка перекладывает в собственные поля, чтобы archlint не зависел от +// internal/{metrics,anomalies}. +type Metrics struct { + Modularity float64 // Q из record modularity/ScopeGraph; NaN если не посчиталось + SpectralGap float64 // record spectral_gap/ScopeGraph + Records []Record // перелож всех metrics.Record + Anomalies []Anomaly // перелож всех anomalies.Anomaly + Ran []string // какие метрики реально отработали + Errors []string // per-metric/per-detector ошибки (не паника) +} + +type Record struct { Metric, Scope, Target string; Value float64; Details map[string]any } +type Anomaly struct { + Metric, Detector string + Score float64 + Code string // Reason.Code + Message string // Reason.Message + Members []string // Region.Members + PrimaryID string +} + +// Опции выбора метрик/детекторов; nil -> всё. +type Options struct { Metrics, Detectors []string } + +func ComputeMetrics(g *archmotifimport.Graph, opt Options) Metrics +func ComputeMetricsContext(ctx context.Context, g *archmotifimport.Graph, opt Options) Metrics +``` + +Внутри `ComputeMetrics`: +1. `res := metrics.Run(g, opt.Metrics)` +2. `anom := anomalies.Run(g, res.Records, opt.Detectors)` +3. Перелож в плоский `Metrics`; вытащить modularity/spectral_gap из ScopeGraph-записей; + собрать Errors из обоих Result. + +Важно: `*archmotifimport.Graph` и `*internal/graph.Graph` — один и тот же тип (alias), +поэтому передаётся в `metrics.Run` без приведения. + +### 2. Адаптер в archlint: интерфейс `MetricsProvider` + две реализации + +`internal/metrics` (новый пакет archlint) или подпакет существующего analyzer: + +```go +type MetricsProvider interface { + // Compute принимает доменную модель archlint и возвращает метрики. + Compute(g model.Graph) (Metrics, error) +} +``` + +Реализация A — `archmotifProvider` (основная, go-import): +- mapping `model.Graph -> archmotifimport.Builder` (раздел 3); +- `archmotifmetrics.ComputeMetrics(built, opts)`; +- перелож в archlint-местный `Metrics`. + +Реализация B — `nativeProvider` (FALLBACK): +- собственный расчёт archlint (degradation, lcom4, reach_srp в internal/mcp). + Используется, когда archmotif недоступен или результат пуст/расошёлся. + +Селектор (фабрика): +```go +func NewProvider(cfg Config) MetricsProvider // дефолт archmotif, fallback native +``` +Триггер fallback: `archmotifProvider.Compute` вернул ошибку ЛИБО `len(Records)==0` / +modularity==NaN при непустом графе. `metrics.Run` ловит ошибки per-metric и НЕ паникует +(Compute по контракту чист), но матричные метрики на gonum — риск; обёртка в форке +оборачивает их `recover()` и кладёт в Errors, чтобы паника одной метрики не валила процесс. + +### 3. Mapping model.Graph -> archmotifimport.Builder + +Builder требует ИЕРАРХИЮ (AddType требует существующий packageID, AddMethod — parentTypeID). +archlint model.Node плоский (ID/Title/Entity), родитель выражен ребром `contains`. Поэтому +адаптер сначала реконструирует иерархию из рёбер, потом наполняет Builder в порядке +package -> type/function -> method -> field -> рёбра-зависимости. + +Узлы (model.Node.Entity -> Builder): +- `package` -> `AddPackage(id, layer, aggregate)` (layer/aggregate можно пустые или из Title) +- `struct`/`type`/`trait`/`enum`/`component` -> `AddType(id, packageID, isInterface, role)` + (isInterface=true для interface/trait; packageID — из contains-ребра) +- `function` -> `AddFunction(id, packageID)` +- `method` -> `AddMethod(id, parentTypeID)` (parentTypeID — из contains/receiver) +- `external*` (external/external_module/external_crate/external_contract) -> либо + `AddPackage` как foreign-узел, либо пропуск (для modularity внешние обычно нужны как стоки зависимостей). + +Рёбра (model.Edge.Type -> Builder): +- `contains` -> `AddContains(parentID, childID)` (ОБРАБОТАТЬ ПЕРВЫМ — даёт иерархию) +- `import` -> `AddDependency(from, to, DependencyDependsOn)` +- `calls` -> `AddDependency(from, to, DependencyCalls)` +- `uses` -> `AddDependency(from, to, DependencyUsesType)` +- `embeds` -> `AddDependency(from, to, DependencyEmbeds)` (или `AddImplements` если это + satisfaction интерфейса — уточнить по семантике archlint embeds) + +DependencyKind в `pkg/archmotifimport`: `DependencyDependsOn/Calls/CallsFrom/References/Embeds/Returns/UsesType`. + +Грабли mapping: +- Builder.Add* возвращают error на дубль ID / отсутствующий parent. Адаптер ДОЛЖЕН + идемпотентно дедуплицировать и пропускать рёбра на неизвестные узлы (а не падать). +- Порядок обязателен: все package -> все type/function -> method/field -> зависимости. + Иначе AddType/AddMethod упадут на отсутствующем родителе. +- Узлы без contains-родителя (осиротевшие type/method) — либо синтетический package, либо drop. + +### 4. Module path форка: require + replace (РЕКОМЕНДАЦИЯ) + +Форк сохраняет `module github.com/kgatilin/archmotif` в go.mod (НЕ переименовывать). +В archlint go.mod: + +``` +require github.com/kgatilin/archmotif v0.0.0- +replace github.com/kgatilin/archmotif => <форк> +``` + +Обоснование (trade-off): +- Вариант REPLACE (рекомендуется): import paths в коде archlint = `kgatilin/archmotif/pkg/*`, + физически тянется форк. Upstream-sync форка ТРИВИАЛЕН — import paths внутри + форка не трогаются, merge от upstream без конфликтов по путям. Цена: в go.mod две + строки (require+replace). pkg/archmotifmetrics — единственный новый код в форке, конфликтовать + с upstream почти не может. +- Вариант RENAME (`module <форк>`): go.mod archlint чище (один + require), НО форк навсегда расходится с upstream в КАЖДОМ из ~45 internal-импортов + -> upstream-sync становится болью (конфликт в каждом файле). Поэтому отвергнут. + +Дефолт = REPLACE ради дешёвого sync. Если важен именно собственный import path в коде — +RENAME допустим, но тогда sync-цена осознанная. + +## Последствия + +Плюсы: +- Нулевое дублирование расчётного аппарата; archlint получает modularity/motif/anomalies даром. +- Граница чистая: archlint видит только `pkg/archmotifmetrics` + `pkg/archmotifimport`, + internal-типы archmotif не протекают (обёртка перекладывает в плоские структуры). +- Fallback на native-метрики -> archlint не падает, если форк недоступен/разойдётся. + +Минусы / риски: +- Mapping иерархии — главный источник багов (порядок, осиротевшие узлы, дубли). Покрыть тестами. +- Связь с форком: archlint зависит от стабильности `pkg/*` API форка. Контракт зафиксирован тут. +- replace-directive: `go get` archlint у третьих лиц без replace не соберётся. + +## План реализации + +Форк archmotif (ветка от main): +1. Создать `pkg/archmotifmetrics/metrics.go` — обёртку из раздела 1. Импортирует свои + `internal/metrics`, `internal/anomalies`, `internal/graph`. Экспорт: `Metrics`, `Record`, + `Anomaly`, `Options`, `ComputeMetrics`, `ComputeMetricsContext`. +2. Внутри: `metrics.Run(g, opt.Metrics)` -> `anomalies.Run(g, res.Records, opt.Detectors)` -> + плоский `Metrics`. Вытащить modularity/spectral_gap из ScopeGraph-записей. Обернуть + вызовы `recover()` на случай паники gonum-метрик, ошибки -> `Metrics.Errors`. +3. `pkg/archmotifmetrics/example_test.go` — построить мини-граф через archmotifimport.Builder, + прогнать ComputeMetrics, проверить что modularity считается. +4. Коммит + push в форк. + +archlint: +5. go.mod: `require github.com/kgatilin/archmotif` + `replace => <форк> `. `go mod tidy`. +6. Новый пакет (напр. `internal/archmetrics`): интерфейс `MetricsProvider`, тип `Metrics`, + `archmotifProvider`, `nativeProvider`, фабрика `NewProvider`. +7. `archmotifProvider`: mapping `model.Graph -> archmotifimport.Builder` (раздел 3, строго + порядок package->type/func->method->deps, идемпотентно, drop рёбер на unknown). Затем + `archmotifmetrics.ComputeMetrics`. +8. `nativeProvider`: завернуть существующий расчёт archlint (degradation/lcom4/reach_srp). +9. Тесты mapping: дубли ID, осиротевшие узлы, fallback-триггер. diff --git a/docs/adr/0002-unified-graph-model-and-metric-tiering.md b/docs/adr/0002-unified-graph-model-and-metric-tiering.md new file mode 100644 index 00000000..2cff83f9 --- /dev/null +++ b/docs/adr/0002-unified-graph-model-and-metric-tiering.md @@ -0,0 +1,293 @@ +# ADR-0002: Единая модель графа archlint, тиринг метрик и граница с archmotif + +- Статус: Accepted +- Связь: уточняет и расширяет ADR-0001 (механизм интеграции archmotif, см. раздел 7) + +## 1. Контекст + +archlint строит `internal/model.Graph` из исходного кода (Go, плюс Rust/TS через +отдельные анализаторы) и считает поверх него ~274 метрики архитектурного качества. +Изначально вся метрика — на Python/NetworkX в каталоге `validator/`, граф передаётся +туда через YAML-сериализацию (`model.Graph` имеет yaml-теги; `validator/graph_loader.py` +читает YAML -> `networkx.DiGraph`). + +Тиры метрик (фактическое состояние): + +| Тир (validator/) | Файлов | Строк | Характер | +|-------------------------|--------|--------|----------| +| structure/core | 2 | 923 | структурное | +| structure/advanced | 3 | 1446 | структурное | +| structure/patterns | 3 | 1025 | структурное | +| structure/research | 19 | 14930 | спектральное/алгебраическое + комбинаторно-взрывное | +| behavior/core | 2 | 640 | структурное (поведенческий граф) | +| behavior/advanced | 2 | 640 | структурное | + +Боль: +- `structure/research` (~14.9k строк, ~188 метрик) набит NP-hard / комбинаторными + метриками с guard'ами вида "Skipped: graph too large O(n^3)/NP-hard" и лимитами + 100-500 узлов. Часть из них НИКОГДА не досчитывается на реальных графах. +- Боевой гейт (агентский loop archlint) тащит за собой Python-рантайм и NetworkX, + хотя в горячем пути нужны только структурные метрики. +- Модель archlint беднее, чем нужно метрикам: `Node{ID,Title,Entity}`, + `Edge{From,To,Method,Type}`. Отношение `implements` живёт ТОЛЬКО в `TypeInfo`, + РЕБРОМ в графе его НЕТ — метрики его не видят. + +Рядом есть archmotif — внешняя библиотека: typed attributed property graph +(`internal/graph`), сериализация в GraphML, Go-математика на gonum (lambda2, modularity, +SCC, quotient-схлопывание, curvature, motif z-score). Архитектурно его словарь богаче +словаря archlint. + +Принцип потребления (гигиена зависимостей): archmotif — внешняя upstream-зависимость. +archlint потребляет её через публичный API, НЕ вендорит и НЕ копирует внутренности. +Математика остаётся единственным источником в владеющей библиотеке (archmotif), что +исключает дублирование логики и расхождение реализаций и упрощает сопровождение. +archlint лишь ЗАВИСИТ от archmotif и конвертирует свою модель в его модель. + +## 2. Решение (обзор) + +1. Единая формальная модель графа остаётся В archlint, владелец = archlint. + Модель повышается до typed attributed property graph и принимает словарь + kind/edge как у archmotif (надмножество текущей модели на том же архитектурном + уровне: модули/типы/функции/стек + type-flow). Сериализация — GraphML. +2. Метрики потребляются graph-agnostic, через graph-интерфейс по образцу + `gonum.org/v1/gonum/graph`. Одна и та же метрика гоняется и на исходном, и на + схлопнутом (quotient) графе без клея. +3. Метрики делятся на 4 тира (раздел 4). +4. archmotif — внешняя ЗАВИСИМОСТЬ за доп-математикой research-тира; потребляется + через публичный API, без вендоринга/дублирования. Граница — in-process Go-import + через export-слой в форке (Вариант B'), fallback — GraphML/CLI (раздел 5). +5. Боевой archlint после миграции = чистый Go (+ archmotif-зависимость для research). + Python остаётся только в deprecated-музее (раздел 6), вне гейта. + +## 3. Формальное определение модели + +Граф архитектуры archlint: + + G = (V, E, λ, μ) + +- V — множество узлов (вершины графа). +- E ⊆ V × V × K_edge — множество типизированных направленных рёбер. +- λ: V -> K_node — функция разметки узла его видом (kind). +- μ: (V ∪ E) -> Attrs — функция свойств: каждому узлу/ребру сопоставлен + JSON-сериализуемый словарь атрибутов (property graph). + +Узел: + + Node { ID string; Kind NodeKind; Name string; QName string; Pos Position; Attrs map[string]any } + +Ребро: + + Edge { From string; To string; Kind EdgeKind; Attrs map[string]any } + +Словарь видов узлов (K_node, принят как у archmotif): + + package, file, type, function, method, field, + loop, branch, goroutine, defer, channelop, syncprim + +Словарь видов рёбер (K_edge, принят как у archmotif): + + contains - структурная вложенность (package contains file/decl, function contains CF-примитив) + implements - конкретный тип реализует интерфейс (НОВОЕ ребро - сейчас в archlint его нет) + embeds - встраивание типа + calls - вызов функции/метода + callsFrom - вызов из control-flow примитива (loop/branch/goroutine/defer) + references - функция/метод как значение (callback) + dependsOn - грубая import-зависимость package/file уровня + returns - тип в сигнатуре возврата (type-flow) + usesType - явное использование типа в теле (type-flow) + +Сериализация: GraphML (общий формат с archmotif — см. раздел 5). + +Потребление: метрики принимают НЕ конкретный `model.Graph`, а минимальный +graph-интерфейс (узлы/соседи/рёбра по образцу `gonum graph.Directed`). Это +делает метрику graph-agnostic — её можно вызвать на исходном G и на quotient(G) +без дублирования кода. + +Замечание о надмножестве: новая модель — строгое надмножество текущей модели +archlint (старые package/type/function/method/contains/calls/dependsOn остаются, +добавляются implements/embeds/returns/usesType/references + control-flow виды). +Существующие анализаторы Go/Rust/TS мигрируют на расширенный словарь; +обязательное приобретение — материализация `implements` РЕБРОМ (из `TypeInfo.Implements`). + +## 4. Тиринг метрик (4 тира) + +Тир определяется НЕ темой метрики, а её ролью в пайплайне и вычислительной +трактуемостью (tractability). + +### Тир 1: fast / slow — боевой гейт + +- Роль: гейт качества в агентском loop archlint. Latency-критично. +- Реализация: чистый Go на gonum. Никакого Python, никакого внешнего процесса. +- Содержимое: структурные метрики (степени, связность, SCC/циклы, instability, + coupling/cohesion, LCOM, reach/SRP, degradation). +- Граница: внутри archlint, без archmotif (gonum достаточно). + +### Тир 2: research — спектральное/алгебраическое + +- Роль: исследовательский слой, НЕ в боевом гейте. Запуск по требованию. +- Остаётся как класс метрик (не вырезается). +- Реализация: tractable-математику (lambda2, modularity, motif z-score, curvature, + quotient-схлопывание, спектр) считает archmotif КАК ЗАВИСИМОСТЬ — он уже умеет + это на Go/gonum. Это НЕ порт Python и НЕ копия логики к себе: archlint + отдаёт граф и получает числа через границу (раздел 5). +- Граница: archlint -> archmotif (см. раздел 5, Вариант B'). + +### Тир 3: deprecated — "музей" + +- Роль: реально замороженные метрики. Из боевого пайплайна ВЫРЕЗАЮТСЯ. +- Критерий заморозки: метрика комбинаторно-взрывная / NP-hard, на реальных графах + не досчитывается (характерное время > 1-3 мин или guard "graph too large" + срабатывает всегда). Кандидаты по характеру (НЕ окончательный список): + maximal cliques, graph automorphism, category_theory, hott, set_theory, prufer. +- Реализация: остаточный Python, запускается РУКАМИ, не в гейте. Музей, не свалка: + код сохранён, но помечен deprecated и исключён из автоматического прогона. +- Окончательный список замороженных метрик берётся из факт-листа профилирования + (характерное время на эталонных графах + срабатывание guard'ов), не выдумывается ADR. + +### Тир 4 (по сути — целевое состояние боевого слоя): структурное Python -> Go + +- Всё структурное, что сейчас на Python (structure/core+advanced+patterns + + behavior/core+advanced, ~86 метрик, ~4.7k строк), переписывается на Go/gonum. +- Обоснование трактуемости порта: ~90% прямое соответствие NetworkX -> gonum + (см. раздел "стратегия миграции"). +- После порта эти метрики живут в Тир1 (fast/slow). + +Итог тиринга: боевой archlint = чистый Go (Тир1) + archmotif-зависимость +(Тир2). Python остаётся ТОЛЬКО в Тир3 (музей, ручной запуск). + +## 5. Граница archlint <-> archmotif (РЕШЕНО: B') + +Требование к границе: archlint потребляет математику archmotif через публичный API, +не вендоря и не копируя внутренности (см. §1, принцип гигиены зависимостей). + +Технический факт: вся математика archmotif (metrics, anomalies, quotient, curvature) +лежит в `internal/` его модуля; Go запрещает импорт чужого `internal/` из другого +модуля. Значит in-process Go-import требует публичной export-поверхности в `pkg/` +зависимости. Поскольку archlint использует форк archmotif, эта поверхность +добавляется в самом форке — тонким export-слоем, без изменения существующей +математики. Это согласуется с принципом: логика остаётся в владеющей библиотеке, +archlint её не копирует. + +### Вариант B' (ПРИНЯТ, основной): Go-import через export-API в форке + +1. В форке archmotif добавляется тонкий публичный пакет `pkg/...` + (export-слой), выводящий наружу metrics / quotient / spectral / anomalies из + `internal/`. Слой тонкий: только проброс существующих функций, без новой + математики и без изменения внутренней логики. +2. archlint импортирует форк как Go-зависимость и зовёт этот публичный API + in-process: строит модель G -> конвертирует в модель archmotif -> вызывает + export-функции -> получает числа. + +Плюсы: настоящий in-process Go-import; типобезопасно; нет процессных накладных и +сериализации; доступно сразу (export-поверхность управляется в собственном форке). +Принцип соблюдён: добавляется только export-поверхность, логика не дублируется в +archlint. +Минусы: форк дивергирует от upstream на export-коммит (см. §8: риски и траектория +сопровождения зависимости). + +Замечание: B' соответствует механизму ADR-0001 (export-пакет в форке); см. §7. + +### Вариант A (FALLBACK): process-граница через GraphML + +archmotif УЖЕ предоставляет публичный CLI: `cmd/archmotif` с подкомандами +`metrics` и `anomalies` ("GraphML in, data out", парсер `internal/graphmlx`). +archlint строит G -> сериализует в GraphML -> запускает `archmotif metrics/ +anomalies` как подпроцесс -> парсит JSON. Роль: fallback, если export-слой в форке +нежелателен или binary-граница окажется удобнее (например, для изоляции тяжёлого +research-прогона в отдельном процессе). Не требует изменений форка — использует +существующий публичный CLI. Минусы: process boundary вместо Go-import; зависимость +от формата CLI/JSON; нужен собранный binary. + +### Решение + +Основной путь = B' (in-process Go-import через export-API в форке) для research-тира. +A (process/GraphML) — задокументированный fallback. Боевой Тир1 от границы НЕ зависит +(чистый Go на gonum внутри archlint). + +## 6. Стратегия миграции Python -> Go + +Принцип: мигрируем по тирам, боевой путь обезпайтонивается первым; research и музей +— следом и по требованию. + +Этап 1 — модель. Расширить `internal/model` до property graph из раздела 3 +(добавить EdgeKind implements/embeds/returns/usesType/references, control-flow +NodeKind, Attrs/μ). Материализовать `implements` ребром из `TypeInfo.Implements`. +Ввести graph-agnostic интерфейс потребления (по gonum). GraphML-сериализация. + +Этап 2 — порт структурного (Тир4 -> Тир1). Переписать ~86 структурных метрик +(~4.7k строк Python) на Go/gonum. ~90% метрик — прямое отображение примитивов +NetworkX -> gonum: + +| NetworkX | gonum | +|----------------------------------|-----------------------------------------| +| DiGraph / Graph | simple.DirectedGraph / UndirectedGraph | +| strongly_connected_components | topo.TarjanSCC | +| degree / in_degree / out_degree | graph.Nodes + From/To | +| shortest_path / bfs | path.DijkstraAllPaths / traverse.BFS | +| pagerank / centrality | network.PageRank / Betweenness | +| connected_components | topo.ConnectedComponents | + +Оставшиеся ~10% (нестандартные обходы) — портируются вручную поверх того же +интерфейса. После порта Python-исходники этих метрик удаляются из боевого слоя. + +Этап 3 — research (Тир2). tractable-математику переключить на archmotif-границу +(раздел 5). Python research-метрики, у которых есть Go-эквивалент в archmotif, +из боевого прогона убираются; считаются через границу по требованию. + +Этап 4 — музей (Тир3). Метрики из факт-листа профилирования (NP-hard/комбинаторные) +помечаются deprecated, исключаются из автоматического гейта, остаются запускаемыми +руками. Не удаляются. + +Гарантия эквивалентности: каждый Go-порт метрики проходит golden-тест против +Python-выхода на эталонных графах (числовое совпадение в пределах epsilon). +Метрики мигрируют по одной, пайплайн остаётся зелёным на каждом шаге. + +## 7. Связь с ADR-0001 + +ADR-0001 ("Интеграция archmotif как поставщика метрик") проектировал механизм +границы как новый публичный пакет-обёртку (export-слой) ВНУТРИ форка archmotif. +Этот механизм совпадает с принятым здесь Вариантом B' (§5). Поэтому: + +- Механизм ADR-0001 (export-пакет в форке) остаётся валидным и является основным + путём этого ADR (B'). ADR-0001 уточняется, а не отменяется. +- Концепция adapter / mapping `model.Graph -> модель archmotif` из ADR-0001 + переиспользуется напрямую: archlint конвертирует свою модель и зовёт export-API + in-process (B'); как fallback — тот же граф уходит через GraphML/CLI (Вариант A). +- Mapping `model.Node.Entity/Edge.Type -> kind/edge словарь` из ADR-0001 переходит + сюда как часть Этапа 1 (теперь это построение property graph, а не разовый адаптер). + +## 8. Последствия + +Плюсы: +- Боевой archlint обезпайтонивается: чистый Go в гейте, быстрый старт, нет + NetworkX-рантайма в горячем пути. +- Модель богаче (implements/type-flow ребрами) -> метрики видят больше структуры. +- Слабое сцепление с archmotif: потребление через публичный API, без вендоринга и + дублирования математики; единственный источник реализации — владеющая библиотека. +- research сохранён как класс метрик, но вынесен из гейта. +- Музей честно отделяет "заморожено навсегда" от "временно медленно". + +Минусы / риски: +- Порт ~86 метрик — объём работы; митигируется golden-тестами и пометочной миграцией. +- B' даёт дивергенцию форка от upstream на export-коммит (см. траекторию ниже). +- Process-граница (Вариант A) добавляет зависимость от собранного binary archmotif + и стабильности его CLI/JSON-контракта. + +Траектория сопровождения зависимости (рекомендуемая практика): +- Сейчас: export-слой живёт в форке archmotif; archlint импортирует форк. +- Дельту форка от upstream держать минимальной (только export-поверхность), чтобы + ребейз export-коммита поверх развития upstream был дешёвым. +- Позже: отправить export-API как PR в upstream. После принятия форк трекает + upstream без расхождения, а export-поверхность становится частью публичного API + библиотеки. Это устраняет дивергенцию как класс. + +Состав Тир3-музея зафиксирован факт-листом профилирования: 23 метрики. +Критерий состава музея — см. DESIGN-RATIONALE.md. + +## 9. Что НЕ входит в этот ADR + +- Код (порт метрик, адаптер, export-слой, сериализация) — реализуется после accepted ADR. +- Изменение внутренней математики archmotif — вне объёма: добавляется только тонкая + публичная export-поверхность в форке (B'), без правки существующей логики. +- Удаление/перемещение метрик — реализация, не ADR. +- Окончательный список замороженных метрик — факт-лист профилирования, не ADR. diff --git a/docs/proof-catalog.md b/docs/proof-catalog.md new file mode 100644 index 00000000..4fb95729 --- /dev/null +++ b/docs/proof-catalog.md @@ -0,0 +1,294 @@ +# Каталог доказуемости archlint + +Каталог сопоставляет каждому архитектурному принципу формальное определение на графе +G=(V,E,λ,μ) (см. модель графа, ADR-0002), метрику и обоснование соундности — доказанную +импликацию "срабатывание метрики ⟹ реальный дефект". Право метрики на severity=ERROR +определяется воротами доказуемости (см. ниже). + +Статусы карточек: ERROR-capable (соундная привязка) / WARNING-max (смежное свойство) / +INFO (дескриптор) / ОТКАЗ (не доказуем на арх-уровне) / BASELINE (нужен diff + intent). + +## Две ортогональные оси каталога + +Каждая карточка получает координату по ДВУМ осям: (паттерн / магнитуда) × (раздражение / разрушение). + +### Ось 1 — СОУНДНОСТЬ: что МОЖЕТ быть гейтом +- СОУНДНЫЙ ГЕЙТ (ERROR-capable) = качественный запрещённый ПАТТЕРН. Наличие структуры X + доказуемо ⟹ дефект. Порог НЕ нужен (наличие бинарно). Примеры: SCC-цикл, ISP usage-subset, + DIP abstraction->detail, слой back-edge, мёртвый код, рост числа компонент связности. +- ДЕСКРИПТОР (INFO/research) = количественный ПОРОГ магнитуды. Порог произволен -> импликации + нет. Примеры: β₁, Q, Ca/Ce/instability/D, fan, condition_number, channel_capacity, d_spec. +- ПРАВИЛО ВОРОТ: ERROR ⟺ детектор ПАТТЕРНА, не превышение ПОРОГА. Режет ~90% самозванцев. +- Сшивка с режимом: ось — для АБСОЛЮТНОГО вердикта. В ДЕЛЬТЕ магнитуда питает + РЕГРЕССИОННЫЙ WARNING (рост от baseline), но НИКОГДА ERROR. Паттерн -> ERROR (абсолют + дельта); + магнитуда -> INFO (абсолют) / WARNING (регрессия). + +### Ось 1б — ГРАДАЦИЯ СОУНДНОСТИ ПАТТЕРНА: closed-world vs open-world +Внутри ERROR-capable паттернов — три градации соундности (не все ERROR одинаковы): +- CLOSED-WORLD (БЕЗУСЛОВНО соунд): паттерн на самом графе, БЕЗ внешнего входа. Граф есть граф, + цикл есть цикл. Пример: SCC/циклы. Требует: ничего. Чистейший ERROR. +- OPEN-WORLD (УСЛОВНО соунд): паттерн соунден ТОЛЬКО относительно ВНЕШНЕГО допущения, которое + в общем случае НЕДОКАЗУЕМО. Пример: dead-code соунден относительно полноты entry-points R, + а R недоказуемо полон (рефлексия, плагины, build-теги, codegen, внешние вызыватели exported). + Паттерн соунден, но соундность УСЛОВНА. Требует КОМПЕНСАЦИИ условности: (1) тройной замок, + (2) ТОЛЬКО дельта-режим (новый дефект vs baseline — издавна пропущенный вход не выстрелит вдруг), + (3) авто-действие human-in-loop. Эти защиты — НЕ произвол, а СЛЕДСТВИЕ open-world условности. +- CLOSED-WORLD НА ПОДДОМЕНЕ: соунд на части графа, ВОЗДЕРЖИВАЕТСЯ вне разрешимого + поддомена (no-verdict, не допущение полноты). Пример: ISP (param-typed + не-форвард + не-контракт). + Между абсолютным closed-world и условным open-world, НО ближе к closed (воздержание != допущение). + Требует: guard воздержания + дельта для легаси. cost=irritation -> БЕЗ тройного замка. +- НЕ ERROR-able: семантика не в графе (DIP). -> WARNING. + +ГРАДИЕНТ соундности: SCC (closed-world, весь граф, безусловен) -> ISP (closed-world, поддомен + +воздержание, guard) -> dead-code (open-world, условен, тройной замок) -> DIP (не ERROR-able). +ПРАВИЛО: ERROR-метрика декларирует свой класс. Защиты = СЛЕДСТВИЕ класса (SCC голый; ISP guard +воздержания; dead-code тройной замок + human-in-loop), не разная осторожность. Ось ПРЕДСКАЗЫВАЕТ +класс по природе дискриминатора: синтаксический -> ERROR-able (SCC/dead-code/ISP); +семантический -> WARNING (DIP). + +### Ось 2 — ЦЕНА ОШИБКИ: как СТРОГО активировать ERROR-метрику (cost_of_false_fire) +- irritation (раздражение): ложное срабатывание = сигнал, код не тронут. Мягкий замок. + Пример: SCC (ложный цикл = шум). ERROR свободно. +- destruction (разрушение): ложное срабатывание у АВТО-действия теряет рабочее. Тройной замок + + human-in-loop; авто-fix ОТДЕЛЁН от сигнала. Пример: мёртвый код (ложно-мёртвый -> удалили живое). +- Ось 1 решает "доказуемо ли", Ось 2 решает "насколько безопасно автоматизировать". +- ФОРМАТ КАРТОЧКИ: поле cost_of_false_fire: irritation | destruction (default irritation; + destruction -> авто-fix требует подтверждения человека). + +--- + +## Двухслойная модель severity + +Severity вердикта = severity-КЛАСС метрики × РЕЖИМ применения: +1. severity-класс — тяжесть класса нарушения (ERROR-capable / WARNING / INFO). +2. режим — в боевом гейте применяется ДЕЛЬТА от baseline (блокируем НОВЫЕ нарушения, + регрессию), НЕ абсолют. Иначе на легаси с 500 нарушениями гейт бесполезен. + +Активация ERROR-блокировки требует: (а) ERROR-capable класс И (б) прохождение размеченного +self-оракула. До разметки self-оракула ERROR-capable метрики работают как WARNING. + +--- + +## SOLID + +### ISP — ERROR-capable (сильнейшая карточка SOLID, близко к iff) +- Определение: интерфейс I с методами m1..mk; клиент C зависит от I (C -dependsOn/uses-> I). +- Метрика: для пары (C,I) — множество S фактически вызываемых C методов I (call + type-flow), + S ⊆ methods(I). +- Обоснование соундности: клиент C использует СТРОГОЕ подмножество S ⊊ methods(I) ⟹ C принуждён + зависеть от methods(I)\S, которые не использует ⟹ ISP нарушен для (C,I). Держится и обратное + (структурное iff при "C связан с I как с целым типом"). +- Порог НЕ произвольный "<=N методов" (size-эвристика — СЛАБАЯ, заменена): + порог = строгое подмножество (тривиален, соунден). +- Вердикт: Тир1 (Go/gonum, call graph через интерфейс). ERROR-capable С 2 GUARD'ами. +- СОУНДНАЯ ФОРМА: ISP-ERROR ⟺ строгое подмножество прямых вызовов И guard1 (i НИКОГДА + не в value-позиции = не форвардится; иначе no-verdict) И guard2 (клиент-метод НЕ реализует + интерфейс = сигнатура не контракт-связана; иначе suppress/WARNING). Свой интерфейс + свой клиент = + ERROR; внешний (io.*) = WARNING. +- ВАЛИДАЦИЯ ОСИ: ISP имеет ТОТ ЖЕ конфаунд, что демотировал DIP (легальное строгое подмножество = + кондуит/форвардинг). Но ISP-дискриминатор СИНТАКСИЧЕСКИЙ (i в value-позиции) -> Ось-1 разрешает + ERROR; DIP-дискриминатор СЕМАНТИЧЕСКИЙ (DTO) -> запретила. Та же ось, обратный исход — ось ПРЕДСКАЗЫВАЕТ. +- КЛАСС: closed-world на РАЗРЕШИМОМ ПОДДОМЕНЕ (воздерживается вне param-typed / не-форвард / не-контракт, + не допускает полноту). cost=irritation -> guard воздержания + дельта, БЕЗ тройного замка. +- Реализация: путь B (MVP param-typed, без go/types — приоритет скорости). Порт после дельта-инфры. +- Уточнение для self-оракула: намеренно широкие интерфейсы (внешние/контрактные, напр. + io.ReadWriteCloser у клиента, юзающего Read) могут давать легитимное срабатывание -> часть + golden expectation = whitelist таких (C,I). В Go узкий клиент широкого интерфейса = РЕАЛЬНЫЙ + запах (надо принимать io.Reader), так что соундность держится; whitelist — для внешних + неизменяемых интерфейсов. + +### DIP — WARNING-max (демотирован из ERROR-capable; self-оракул поймал натяжку) +- Определение: абстракция = тип с kind=interface; деталь = конкретная реализация ПОВЕДЕНИЯ/политики. +- Метрика: рёбра usesType/returns от узла-интерфейса к узлу kind=concrete. +- ПОЧЕМУ НЕ ERROR (self-прогон: 14/15 срабатываний на ЛЕГАЛЬНЫХ DTO-возвратах = само-фальсификация): + - "Деталь" в классическом DIP = конкретика С ПОВЕДЕНИЕМ, а не любой конкретный тип. DTO/value-object, + возвращаемый интерфейсом = СЛОВАРЬ абстракции (данные), не деталь (нечего инвертировать). + - STRICT (всякий concrete = деталь) доказывает НАДМНОЖЕСТВО ("ссылается на concrete") ⊋ принцип + DIP ("зависит от поведенческой детали"). Та же категориальная ошибка, что β₁ vs SCC. + - Дискриминатор DTO/behavioral НЕформализуем соундно — все кандидаты (есть методы? доля полей? + реализует интерфейс?) = магнитуда/эвристика -> по оси-1 ERROR-право не дают. + - "Деталь" — СЕМАНТИЧЕСКОЕ понятие, его НЕТ в графе G. DIP не ERROR-able граф-соундно. +- Вердикт: Тир1, WARNING-max (как SRP/single-impl). cost_of_false_fire=irritation. +- ERROR-КОР НЕ ТЕРЯЕТСЯ: по-настоящему злой DIP = абстракция->конкрет создаёт ИМПОРТНЫЙ ЦИКЛ — + это УЖЕ ловит SCC как ERROR (карточка циклов). ERROR-достойные DIP = подмножество циклов, держит + SCC-пол. Остаток (ссылка на поведенческую деталь без цикла) = дизайн-сигнал суждения = WARNING. +- Бонус (не меняет соундность): эвристику DTO/behavioral можно использовать для РАНЖИРОВАНИЯ + WARNING'ов (поднять настоящий запах над DTO-шумом). WARNING терпит эвристику, ERROR — никогда. +- УРОК: DIP держался как флагманская SOLID-ERROR. Self-прогон на реальном коде поймал натяжку. + Это валидирует двухслойные ворота: self-оракул — не формальность, а фильтр, режущий натяжки ДО прода. +- Отдельно: дистанция D=|A+I−1| (abstractness vs instability) — ЭВРИСТИКА-дескриптор, + severity INFO, НЕ соундный детектор. Не путать с детектором выше. + +### SRP — WARNING-max (семантический разрыв, честная граница) +- Определение: "ответственность" — семантика, в графе G её НЕТ. Структурная тень = когезия. +- Метрика: LCOM4-граф типа T (узлы=методы, ребро = общий доступ к полю ИЛИ внутр. вызов); + κ(T) = число компонент связности. +- Обоснование: κ(T)>=2 ⟹ методы разбиваются на >=2 групп без общих полей и взаимных вызовов + ⟹ T допускает РАЗБИЕНИЕ с нулевой связностью. Доказывает ДЕКОМПОЗИРУЕМОСТЬ, НЕ "число + ответственностей". Контрпример: Vector{x,y,z} с несвязными методами даёт κ>=2 при одной + ответственности. +- Вердикт: WARNING-max (кандидат на декомпозицию), НЕ ERROR. SRP структурно НЕдоказуем. +- Метрику подавать под ЧЕСТНЫМ именем "decomposability / zero-coupling split detectable", + НЕ под именем "SRP". Не выдавать декомпозируемость за SRP. + +### LSP — ОТКАЗ (не доказуем на арх-уровне, публичная граница) +- LSP = поведенческая субтипизация (pre/post/инварианты) — семантика/поведение, не статика графа. +- На арх-уровне соундной импликации НЕТ. Сигнатурная конформность (тень LSP) уже гарантирована + компилятором Go при satisfaction интерфейса -> graph-метрика соундного не добавляет. Реальные + LSP-нарушения (panic наружу, сужение поведения) требуют control-flow анализа НИЖЕ арх-уровня. +- archlint НЕ претендует доказывать LSP на арх-графе — честная граница. Сигнатурную проверку для Go + НЕ держим: компилятор уже гарантирует -> метрика была бы избыточной (анти-редундантность: не + дублировать уже гарантированное). Для языков без статической satisfaction-проверки + (TS duck typing) — отложенный вопрос. + +### OCP — BASELINE-условная (нужен diff + intent) +- OCP про ЭВОЛЮЦИЮ: добавление поведения не модифицирует существующий код. В одном снимке G + непроверяем. +- Метрика: над диффом Δ(G_old, G_new): для предсуществующих узлов V_old — число изменённых + исходящих рёбер. +- Обоснование: изменены рёбра V_old при изменении-расширении ⟹ существующий код модифицирован + ⟹ OCP нарушен. СОУНДНО ПРИ УСЛОВИИ, что изменение классифицировано как "чистое расширение" + (intent — внешний вход, граф его не знает). Структурная часть (V_old тронут?) соундна. +- Вердикт: Тир-с-baseline (регрессионная метрика). Без метки intent — дескриптор diff. + +ИТОГ SOLID: ERROR-capable — только ISP (на проверке self-горнилом перед промотацией). WARNING-max — +SRP (decomposability), DIP (демотирован: семантика "детали" не в графе; ERROR-кор держит SCC-цикл). +ОТКАЗ — LSP. BASELINE — OCP. Из 5 принципов граф-соундный ERROR даёт лишь ISP, и тот проходит +self-горнило. Остальное — WARNING/отказ. Это не слабость — это предельная честность о границе +доказуемого. + +## Циклы зависимостей + +### SCC — ERROR-capable (чистое iff, соундно + полно) +- Определение: цикл в ориентированном графе зависимостей. +- Метрика: Tarjan SCC. Цикл ⟺ SCC размера >1 ИЛИ петля. +- Обоснование: чистое iff, соундно И полно (любой SCC>1 = цикл, без лимита длины). +- Вердикт: Тир1, ERROR-capable. Это ПАТТЕРН (наличие SCC>1), не магнитуда -> ось подтверждает. +- Замечание о полноте: наивная цикл-детекция через перечисление простых циклов с ограничением + длины (напр. max_length=10) ПРОПУСКАЕТ длинные циклы (неполнота -> ложно-зелёный гейт). Tarjan + SCC закрывает дыру: цикл ⟺ SCC>1 без лимита длины. +- Та же SCC-машина обслуживает слоистость-floor (ниже, Уровень A). + +## Слоистость + +Слой = назначение узлов на уровни L: V -> {1..n}. L — ВНЕШНИЙ вход (декларация архитектора, +как intent в OCP). Принцип: межслойные рёбра в одну сторону, граф слоёв = DAG. + +### Уровень A — floor, label-free (без декларации L) — ERROR-capable +- Метрика: Tarjan SCC на модульном dependsOn-графе (узлы = пакеты/модули). +- Обоснование: SCC>1 среди модулей ⟺ цикл модульных зависимостей. iff, соундно + полно. + Работает БЕЗ объявления слоёв (тот же SCC-механизм, что "нет циклов", на модульной гранулярности). +- Вердикт: Тир1, ERROR-capable. ПАТТЕРН. + +### Уровень B — label-dependent (нужна декларация L) — ERROR-capable при наличии L +- Метрика: межслойные рёбра против порядка L (back-edges в фактор-графе G/L). +- Обоснование: ребро (u∈L_i -> v∈L_j) против объявленного порядка ⟹ слой нарушен. Глобально: + G/L имеет цикл ⟺ слои не DAG. Соундно ОТНОСИТЕЛЬНО объявленного L (как DIP на уровне слоёв). +- Вердикт: Тир1, ERROR-capable при наличии L. ПАТТЕРН (back-edge). + +### modularity Q (Newman) — INFO-дескриптор (НЕ доказывает слоистость) +- Q меряет наличие СООБЩЕСТВ (плотных групп), а сообщества ≠ слои. Q направленно-СЛЕП + (неориент./симметризованный граф), слоистость ОРИЕНТИРОВАНА. Высокий Q при сломанной + слоистости и наоборот — возможны. Та же категориальная ошибка, что β₁ (неориент) под цикл (ориент). +- Вердикт: Тир2/research, INFO. Оптимизационный дескриптор "есть ли модульность вообще". МАГНИТУДА. + +## Связность / связанность + +- COHESION (внутренняя): соундный детектор УЖЕ есть — карточка decomposability (κ LCOM4). + НЕ дублируется (анти-редундантность). Ссылка на SRP -> decomposability. +- COUPLING-МАГНИТУДА (Ca, Ce, instability I, дистанция D, fan-in/out): непрерывные дескрипторы, + соундного "coupling>порог ⟹ дефект" НЕТ (порог произволен). Все -> INFO/research, НЕ ERROR. МАГНИТУДА. +- СОУНДНЫЙ COUPLING-ГЕЙТ живёт в КАЧЕСТВЕННЫХ паттернах, не в магнитуде: DIP-ребро, + слой-back-edge, SCC-цикл — уже окарточены. Магнитуда связанности = только дескриптор. + +## Мёртвый код / Дубли / Overengineering + +### Мёртвый код — ERROR (open-world условно-соундный; паттерн reachability) +СТАТУС: ПЕРВЫЙ open-world ERROR, прошедший полное горнило (после closed-world SCC). На self: +89 -> 5 мёртвых, 0 false-dead, 5 реальных находок. Две валидации на реальных данных: +(А) qname-идентичность модели ТОЧНЕЕ грепа (коллизия одноимённых функций развелась автоматически по пакету); +(Б) граница test-only эмпирична (функция, вызываемая тестом, живая; осиротевшие тест-хелперы мёртвы). +КОМПЕНСАЦИИ ОБЯЗАТЕЛЬНЫ (open-world): дельта-режим + тройной замок + human-in-loop удаление. + +- Определение: узел недостижим от множества entry points R (HTTP/gRPC-хендлеры, консьюмеры + брокера, cron/тикеры, main, lifecycle; для библиотеки R = ВСЕ exported) по рёбрам calls/references. +- Обоснование: u ∉ reach(R) ⟹ u не вызывается ни одним путём от триггера ⟹ u мёртв. Соундно + ОТНОСИТЕЛЬНО полного R. Качественный ПАТТЕРН (reachability бинарна) -> ось даёт ERROR-capable. +- Оговорка (соундность условна полноте R): пропущенный entry point (рефлексия, init(), + колбэки, exported API) -> ложно-мёртвый. Whitelist: explicit entry-маркеры. +- Вердикт: Тир1, ERROR-capable ПРИ полном R (OPEN-WORLD класс, Ось-1б: условно-соунд). Дельта: + новый недостижимый узел = сильная регрессия. +- ТЕСТ-ДОСТИЖИМОСТЬ = ЖИВОСТЬ: R и reference-граф ВКЛЮЧАЮТ тесты (_test.go calls/refs, + Test*-функции как entry). "Недостижим из prod R" ≠ "безопасно удалить" — право на удаление даёт + только недостижимость ВКЛЮЧАЯ тесты. Иначе test-only хелпер -> ложно-мёртвый -> удалить -> + сломать тесты (destruction). Прецедент: функция, вызванная только из *_test.go, НЕ мёртва. +- НЕ ПУТАТЬ с test-only-smell (ниже) — разные принципы, разная цена ошибки. + +### test-only-smell — WARNING (двойной демотер) +- Прод-функция, достижимая ТОЛЬКО из тест-R, не из прод-R. Guard ¬exported (exported = корни прод-R). +- ПОЧЕМУ WARNING (двойной демотер): (1) ИНТЕНТ-вердикт (как SRP — "плохо ли" интент-зависимо: + planned-wiring legit) + (2) OPEN-WORLD (как dead-code — условен полноте прод-R). dead-code заслужил + ERROR вопреки open-world (вердикт не интент-зависим); test-only имеет ОБА демотера -> строго слабее + -> твёрдо WARNING. cost=irritation. +- УНИФИКАЦИЯ с dead-code (один reach-проход, три вердикта по root-set): никем не достигнут -> + dead-code (ERROR); только тест-R -> test-only (WARNING); прод-R -> живой. Партиция R на прод/тест. +- АСИММЕТРИЯ ЦЕНЫ ОШИБКИ: ложно-мёртвый (удалили рабочее) >> ложно-живой (оставили мёртвое). + Поэтому у мёртвого кода — ТРОЙНОЙ ЗАМОК активации: (1) R через КОНФИГ+правила, не эвристика + (неполный R убивает живой код); (2) режим ДЕЛЬТА (новое мёртвое, не легаси-аудит); (3) self-оракул + чист. AUTO-FIX (удаление) по мёртвому коду НЕ автоматичен без подтверждения человека — здесь цена + ошибки смертельна (потеря живого кода). + +### Дубли / копипаста — в основном ДЕСКРИПТОРЫ (ERROR нет) +- motif z-score — МАГНИТУДА (порог vs null-model) -> INFO/research; рост = регрессия-WARNING. +- спектральная симметрия / автоморфизмы — магнитуда-дескриптор, INFO, регрессия осмыслена. +- структурный КЛОН (изоморфные подграфы, совпадение λ + call-targets) — ПАТТЕРН, но соундность + для "вредного дубля" СЛАБА: изоморфизм ⟹ сходство, НЕ ⟹ вредная копипаста (легитимная + повторяемость есть: два CRUD-хендлера одной формы). Необходимо-не-достаточно. +- Честная граница: на арх-уровне доказуемо СХОДСТВО, не "вредность" (вредность=intent). Реальная + token/AST-копипаста — НИЖЕ арх-уровня (expression), намеренно исключена. -> клон = WARNING-max. +- Вердикт: z-score / симметрия -> INFO + регрессия-WARNING (Тир2). Структурный клон -> WARNING. ERROR нет. + +### Overengineering — WARNING/дескрипторы (ERROR нет, самый intent-зависимый) +- "интерфейс с 1 реализацией" — ПАТТЕРН (count implements в I = 1, I свой), но необходимо-не- + достаточно: легитимные single-impl (DI-шов для моков, плановое расширение, plugin-seam). + Доказывает "абстракция без полиморфизма", НЕ overengineering-дефект. WARNING-max + whitelist (test/DI). +- "глубина потока" depth>N — МАГНИТУДА -> INFO; рост = регрессия-WARNING. +- "fan-out" (произвольный порог <=5) — МАГНИТУДА -> INFO; рост = регрессия-WARNING. +- "лишний слой" — нужен L-конфиг + суждение, не соундный паттерн -> дескриптор. +- Честная граница: overengineering самый intent-зависимый, "доказать overengineering" было бы + слабейшей претензией. Честно демотируем в сигналы. +- Вердикт: single-impl -> WARNING + whitelist; depth/fan-out/слой -> INFO + регрессия-WARNING. ERROR нет. + +Итог секции (подтверждает ось): ERROR-capable — только мёртвый код (паттерн reachability). Всё +остальное WARNING (структурный клон, single-impl) или INFO + регрессия-WARNING (z-score, симметрия, +depth, fan-out). Один чистый паттерн -> гейт; масса магнитуд -> дескрипторы в регрессионном контуре. + +## Регрессия архитектуры + +Боевой режим гейта (гейт = дельта) строится на ДВУХ соундных механизмах + 1 скрине. + +### Pattern-delta — ERROR-capable (соундный регрессионный контур) +- Появление запрещённого ПАТТЕРНА в new, которого не было в baseline ⟹ регрессия: + новый SCC-цикл / новое DIP-ребро абстракция->деталь / новый layer back-edge / новый + недостижимый узел. Соундно (паттерн соунден + "появился" качественно бинарно). +- Вердикт: ERROR-capable. Это и есть боевой гейт регрессии. cost_of_false_fire наследуется + от паттерна (напр. новый-мёртвый -> destruction, авто-fix под замком). + +### Спектральный инвариант компонент — ERROR-capable (целочисленный, не порог) +- components(new) > components(base) ⟹ граф фрагментировался. Кратность нулевого собств. + значения лапласиана — ЦЕЛОЕ, не порог -> соундный сигнал (внутри спектра та же ось: + целочисленный инвариант = паттерн, непрерывное расстояние = дескриптор). +- Вердикт: ERROR-capable. + +### d_spec (спектральное расстояние ||λ(G_base)−λ(G_new)||) — INFO global-screen +- МАГНИТУДА И качественно-СЛЕПО: меряет ВЕЛИЧИНУ сдвига, не НАПРАВЛЕНИЕ. Здоровый рефакторинг + даёт большое d_spec ровно как деградация. Третья слепота в ряду: Q (слои), β₁ (циклы), d_spec (качество). +- ВЕРДИКТ: Тир2/research, INFO. НЕ соундный вердикт (порог дельты произволен + слепота к знаку). +- УНИКАЛЬНАЯ ЦЕННОСТЬ (не выкидываем): глобальный скрин ДИФФУЗНОГО сдвига — ловит "смерть от + тысячи порезов" (много мелких изменений, ни одно не пробивает локальный порог, но структура + поехала), что покомпонентные магнитуды пропускают. Роль: дешёвый global tripwire -> РОУТИТ + на per-principle diff, сам вердикта не выносит. Агрегат-комплемент к локальным магнитуда-WARNING. +- Спектральная математика сохранена (Тир2), но гейт держит ПАТТЕРН, не d_spec. + +ИТОГ: первый проход каталога ПОЛОН (покрыта вся карта принципов). Гейт регрессии = pattern-delta ++ компонент-инвариант (ERROR-capable); d_spec = INFO-скрин-роутер. diff --git a/go.mod b/go.mod index f50fa793..c42c629a 100644 --- a/go.mod +++ b/go.mod @@ -6,18 +6,24 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/olive-io/bpmn/schema v1.7.1 github.com/spf13/cobra v1.10.1 + gonum.org/v1/gonum v0.15.1 gopkg.in/yaml.v3 v3.0.1 ) +require golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect + require ( github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kgatilin/archmotif v0.0.0-00010101000000-000000000000 github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect golang.org/x/arch v0.14.0 // indirect golang.org/x/sys v0.38.0 // indirect ) + +replace github.com/kgatilin/archmotif => github.com/mshogin/archmotif v0.0.0-20260612131558-5902862ad7cd diff --git a/go.sum b/go.sum index 6df549f2..c2286ae8 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/mshogin/archmotif v0.0.0-20260612131558-5902862ad7cd h1:yUz1vbkpXUoU83S8Yp42I+wFewNyt2AsFiZhrRgU00M= +github.com/mshogin/archmotif v0.0.0-20260612131558-5902862ad7cd/go.mod h1:43CTWHP2AcOJ3CtQ+GcLAG5TvyTQUdLwtdIdBbxN9UQ= github.com/olive-io/bpmn/schema v1.7.1 h1:qzDr8GfgLsvKxuObZuA3Kwcg0+rYBCLrwdN9g7a2wq8= github.com/olive-io/bpmn/schema v1.7.1/go.mod h1:I+gdW8VEI52SZ/uhIrMuUgX0k8mEzLxbRBGsVyBMAww= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -39,8 +41,18 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0= +gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/analyzer/go.go b/internal/analyzer/go.go index 1bcfea31..d3d13e1c 100644 --- a/internal/analyzer/go.go +++ b/internal/analyzer/go.go @@ -20,6 +20,7 @@ type GoAnalyzer struct { nodes []model.Node edges []model.Edge excludeDirs []string + pkgRefs map[string][]CallInfo // package-level function-value-use ((а)-фикс) } // PackageInfo holds information about a package. @@ -48,6 +49,9 @@ type CallInfo = model.CallInfo // FieldAccessInfo is an alias for model.FieldAccessInfo for backward compatibility. type FieldAccessInfo = model.FieldAccessInfo +// InterfaceMethodSig is an alias for model.InterfaceMethodSig. +type InterfaceMethodSig = model.InterfaceMethodSig + // NewGoAnalyzer creates a new Go code analyzer. func NewGoAnalyzer() *GoAnalyzer { return &GoAnalyzer{ @@ -57,6 +61,7 @@ func NewGoAnalyzer() *GoAnalyzer { methods: make(map[string]*MethodInfo), nodes: []model.Node{}, edges: []model.Edge{}, + pkgRefs: make(map[string][]CallInfo), } } @@ -70,6 +75,7 @@ func (a *GoAnalyzer) WithExcludeDirs(dirs []string) *GoAnalyzer { // Analyze analyzes a directory containing Go code. func (a *GoAnalyzer) Analyze(dir string) (*model.Graph, error) { parser := newGoParser(a.packages, a.types, a.functions, a.methods) + parser.pkgRefs = a.pkgRefs err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -88,7 +94,11 @@ func (a *GoAnalyzer) Analyze(dir string) (*model.Graph, error) { return nil } - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + // _test.go ТОЖЕ парсим (test-reachability, Фаза 3): тест-вызовы/ссылки в граф, + // иначе test-only хелперы (вызваны лишь из теста) ложно-мёртвы -> destruction + // (удалить -> сломать тест). Test*/Benchmark*/Example* в авто-R -> прод-функция, + // вызванная только из теста, достижима через них. + if !strings.HasSuffix(path, ".go") { return nil } @@ -99,6 +109,7 @@ func (a *GoAnalyzer) Analyze(dir string) (*model.Graph, error) { } builder := newGoGraphBuilder(a.packages, a.types, a.functions, a.methods, &a.nodes, &a.edges) + builder.pkgRefs = a.pkgRefs builder.buildGraph() return &model.Graph{ @@ -131,6 +142,7 @@ func (a *GoAnalyzer) FindImplementations(interfaceID string) []string { } builder := newGoGraphBuilder(a.packages, a.types, a.functions, a.methods, &a.nodes, &a.edges) + builder.pkgRefs = a.pkgRefs var result []string @@ -170,6 +182,7 @@ func (a *GoAnalyzer) AllTypes() map[string]*TypeInfo { // ResolveCallTarget resolves a call target to a node ID (public access). func (a *GoAnalyzer) ResolveCallTarget(call CallInfo, callerPkg string) string { builder := newGoGraphBuilder(a.packages, a.types, a.functions, a.methods, &a.nodes, &a.edges) + builder.pkgRefs = a.pkgRefs return builder.resolveCallTarget(call, callerPkg) } diff --git a/internal/analyzer/go_graph.go b/internal/analyzer/go_graph.go index 6850f3ca..c8346e76 100644 --- a/internal/analyzer/go_graph.go +++ b/internal/analyzer/go_graph.go @@ -14,6 +14,9 @@ type GoGraphBuilder struct { methods map[string]*MethodInfo nodes *[]model.Node edges *[]model.Edge + // pkgRefs — package-level function-value-use (cobra RunE и т.п.), (а)-фикс. + // Выставляется go.go (= a.pkgRefs). nil у тест-билдеров -> обрабатывается мягко. + pkgRefs map[string][]CallInfo } // newGoGraphBuilder создает новый построитель графа. @@ -45,10 +48,244 @@ func (g *GoGraphBuilder) buildGraph() { g.buildFunctionCallEdges() g.buildMethodCallEdges() g.buildTypeDependencyEdges() + g.buildImplementsEdges() + g.buildSignatureEdges() + g.buildReferenceEdges() g.buildFieldAccessNodes() g.buildFieldAccessEdges() } +// buildReferenceEdges материализует ребро owner -> функция/метод, использованные +// как ЗНАЧЕНИЕ (callback): символ в НЕ-call позиции (assigned/passed/address-taken), +// собранный парсером (collectFuncRefs). +// +// Резолв цели = ИСТИННАЯ OVER-APPROXIMATION ПО ИМЕНИ: символ с именем N (последний +// сегмент) -> ребро на ВСЕ функции/методы с этим именем, без var-type inference +// (value-flow ниже арх-уровня, исключён). Это ДОБАВЛЯЕТ достижимость -> ложно-живой +// (дёшево), ложно-мёртвый НЕВОЗМОЖЕН (тот же линчпин, что implements; destruction- +// безопасно для dead-code Фазы 3). Имена, не совпадающие ни с одной функцией/методом +// (локальные переменные dir/err/...), просто не дают рёбер. +func (g *GoGraphBuilder) buildReferenceEdges() { + // индекс: имя (последний сегмент ID) -> все функции/методы с этим именем. + byName := make(map[string][]string) + index := func(id string) { + byName[lastSegment(id)] = append(byName[lastSegment(id)], id) + } + + for id := range g.functions { + index(id) + } + for id := range g.methods { + index(id) + } + + seen := make(map[[2]string]bool) + emit := func(from, to string) { + if from == "" || to == "" || from == to { + return + } + + k := [2]string{from, to} + if seen[k] { + return + } + + seen[k] = true + *g.edges = append(*g.edges, model.Edge{From: from, To: to, Type: model.EdgeReferences}) + } + + link := func(ownerID string, refs []CallInfo) { + for _, ref := range refs { + for _, target := range byName[lastSegment(ref.Target)] { + emit(ownerID, target) + } + } + } + + for funcID, fi := range g.functions { + link(funcID, fi.Refs) + } + for methodID, mi := range g.methods { + link(methodID, mi.Refs) + } + + // (а)-фикс: package-level var/const function-value-use. Источник = .init + // (выполняется при загрузке пакета, default-entry в R), а если init нет — + // package-узел (ссылка не теряется). g.pkgRefs nil у тест-билдеров -> пропуск. + for pkgID, refs := range g.pkgRefs { + src := pkgID + if _, ok := g.functions[pkgID+".init"]; ok { + src = pkgID + ".init" + } + + link(src, refs) + } +} + +// lastSegment возвращает имя после последней точки (символ из ID или ref.Target). +func lastSegment(s string) string { + if i := strings.LastIndex(s, "."); i >= 0 { + return s[i+1:] + } + + return s +} + +// buildSignatureEdges материализует type-refs из СИГНАТУР функций/методов (Фаза 1): +// - usesType (model.EdgeUses): owner -> тип ПАРАМЕТРА (расширяет "uses", который +// раньше покрывал только типы полей struct). Ключ полноты DIP: у интерфейса нет +// тела -> param-типы единственный сигнал param-нарушений. +// - returns (model.EdgeReturns): owner -> тип ВОЗВРАТА (type-flow). +// +// Соундность приоритетна: resolveTypeDependency эмитит ребро ТОЛЬКО на известный +// тип-узел (примитивы/внешние/нерезолвимые -> нет ребра), поэтому ложного ребра на +// легальном коде не будет. Неполнота (пропуск нерезолвимого) — в дешёвую сторону. +func (g *GoGraphBuilder) buildSignatureEdges() { + seen := make(map[[3]string]bool) + + emit := func(from, to, etype string) { + if from == "" || to == "" || from == to { + return + } + + k := [3]string{from, to, etype} + if seen[k] { + return + } + + seen[k] = true + *g.edges = append(*g.edges, model.Edge{From: from, To: to, Type: etype}) + } + + sig := func(ownerID, pkg string, params, results []model.FieldInfo) { + for _, p := range params { + emit(ownerID, g.resolveTypeDependency(p.TypeName, p.TypePkg, pkg), model.EdgeUses) + } + + for _, r := range results { + emit(ownerID, g.resolveTypeDependency(r.TypeName, r.TypePkg, pkg), model.EdgeReturns) + } + } + + for funcID, fi := range g.functions { + sig(funcID, fi.Package, fi.Params, fi.Results) + } + + for methodID, mi := range g.methods { + sig(methodID, mi.Package, mi.Params, mi.Results) + } + + // Сигнатуры методов ИНТЕРФЕЙСА: ребро ОТ интерфейса (абстракция) к типу в + // param/return метода. DIP по типовому уровню: интерфейс, ссылающийся на + // КОНКРЕТ в сигнатуре своего метода = нарушение (источник=абстракция, + // цель=деталь). resolveTypeDependency -> только известный тип (соундность). + for ifaceID, ti := range g.types { + if ti.Kind != "interface" { + continue + } + + for _, ms := range ti.MethodSigs { + sig(ifaceID, ti.Package, ms.Params, ms.Results) + } + } +} + +// buildImplementsEdges материализует ребро concrete-type -> interface по +// method-set сатисфакции, ПОЛНО с embeds-промоушеном (Go-embedding промоутит +// методы встроенного типа/интерфейса). Критерий: requiredMethods(I) ⊆ +// providedMethods(T), где оба множества раскрывают embeds рекурсивно. +// +// Сопоставление ПО ИМЕНИ метода (не по полной сигнатуре — сигнатуры пока не в +// модели). Это КОНСЕРВАТИВНАЯ over-approximation: может дать лишние implements, +// но НЕ пропустит реальную реализацию -> для dead-code reach (Фаза 3) это +// БЕЗОПАСНАЯ сторона (не удалит живую реализацию интерфейса). DR-0005: полнота +// implements — критерий, неполнота рушит reach в дорогую сторону. +func (g *GoGraphBuilder) buildImplementsEdges() { + // methodSet(typeID) — множество имён методов типа с раскрытием embeds-промоушена. + // memo + placeholder-guard от циклов (в Go embedding-циклов нет, но безопасно). + memo := make(map[string]map[string]bool) + + var methodSet func(typeID string) map[string]bool + methodSet = func(typeID string) map[string]bool { + if m, ok := memo[typeID]; ok { + return m + } + + memo[typeID] = map[string]bool{} // placeholder: рвём возможный цикл + + ti := g.types[typeID] + if ti == nil { + return memo[typeID] + } + + m := make(map[string]bool) + + // собственные методы интерфейса (имена из сигнатур) + for _, ms := range ti.MethodSigs { + m[ms.Name] = true + } + + // собственные receiver-методы конкретного типа + for _, mi := range g.methods { + if mi.Package == ti.Package && mi.Receiver == ti.Name { + m[mi.Name] = true + } + } + + // промоушен: методы встроенных типов/интерфейсов (рекурсивно) + for _, emb := range ti.Embeds { + embID := g.resolveTypeDependency(emb, "", ti.Package) + if embID == "" || embID == typeID { + continue + } + + for name := range methodSet(embID) { + m[name] = true + } + } + + memo[typeID] = m + + return m + } + + for ifaceID, iface := range g.types { + if iface.Kind != "interface" { + continue + } + + req := methodSet(ifaceID) + if len(req) == 0 { + continue // пустой интерфейс (any) — не плодим тривиальные рёбра + } + + for typeID, t := range g.types { + if t.Kind != "struct" || typeID == ifaceID { + continue + } + + prov := methodSet(typeID) + + implementsAll := true + for name := range req { + if !prov[name] { + implementsAll = false + + break + } + } + + if implementsAll { + *g.edges = append(*g.edges, model.Edge{ + From: typeID, + To: ifaceID, + Type: model.EdgeImplements, + }) + } + } + } +} + func (g *GoGraphBuilder) buildPackageNodes() { for pkgID, pkg := range g.packages { *g.nodes = append(*g.nodes, model.Node{ @@ -61,10 +298,18 @@ func (g *GoGraphBuilder) buildPackageNodes() { func (g *GoGraphBuilder) buildTypeNodes() { for typeID, typeInfo := range g.types { + // Attrs.kind — ось абстрактности (interface|concrete) для DIP. Entity + // оставляем как было (struct/interface) для обратной совместимости. + kind := model.KindConcrete + if typeInfo.Kind == "interface" { + kind = model.KindInterface + } + *g.nodes = append(*g.nodes, model.Node{ ID: typeID, Title: typeInfo.Name, Entity: typeInfo.Kind, + Attrs: map[string]any{"kind": kind}, }) *g.edges = append(*g.edges, model.Edge{ diff --git a/internal/analyzer/go_parser.go b/internal/analyzer/go_parser.go index be0ef9c8..a1ee1f23 100644 --- a/internal/analyzer/go_parser.go +++ b/internal/analyzer/go_parser.go @@ -15,6 +15,9 @@ type GoParser struct { types map[string]*TypeInfo functions map[string]*FunctionInfo methods map[string]*MethodInfo + // pkgRefs — package-level function-value-use по пакетам (а)-фикс. Делится с + // builder через analyzer (go.go выставляет parser.pkgRefs = a.pkgRefs). + pkgRefs map[string][]CallInfo } // newGoParser создает новый парсер, работающий с переданными хранилищами данных. @@ -69,8 +72,13 @@ func (p *GoParser) parseFile(filename string) error { for _, decl := range node.Decls { switch d := decl.(type) { case *ast.GenDecl: - if d.Tok == token.TYPE { + switch d.Tok { + case token.TYPE: p.parseTypeDecl(d, pkgID, filename, fset) + case token.VAR, token.CONST: + // (а)-фикс: package-level var/const инициализаторы тоже несут + // function-value-use (cobra `var c=&Command{RunE:H}`, slices, maps). + p.collectPackageLevelRefs(d, pkgID) } case *ast.FuncDecl: p.parseFuncDecl(d, pkgID, filename, fset) @@ -97,24 +105,27 @@ func (p *GoParser) parseTypeDecl(decl *ast.GenDecl, pkgID, filename string, fset var embeds []string + var methodSigs []InterfaceMethodSig + switch t := typeSpec.Type.(type) { case *ast.StructType: kind = "struct" fields, embeds = p.parseStructFields(t) case *ast.InterfaceType: kind = "interface" - embeds = p.parseInterfaceEmbeds(t) + embeds, methodSigs = p.parseInterfaceEmbeds(t) } pos := fset.Position(typeSpec.Pos()) p.types[typeID] = &TypeInfo{ - Name: typeName, - Package: pkgID, - Kind: kind, - File: filename, - Line: pos.Line, - Fields: fields, - Embeds: embeds, + Name: typeName, + Package: pkgID, + Kind: kind, + File: filename, + Line: pos.Line, + Fields: fields, + Embeds: embeds, + MethodSigs: methodSigs, } } } @@ -144,22 +155,33 @@ func (p *GoParser) parseStructFields(structType *ast.StructType) (fields []Field return fields, embeds } -// parseInterfaceEmbeds извлекает встроенные интерфейсы. -func (p *GoParser) parseInterfaceEmbeds(iface *ast.InterfaceType) []string { - var embeds []string - +// parseInterfaceEmbeds извлекает встроенные интерфейсы (method.Names==0) И +// ПОЛНЫЕ сигнатуры собственных методов интерфейса (method.Names!=0): имя + +// param/return type-refs. Имена -> method-set implements; param/return -> рёбра +// usesType/returns ОТ интерфейса (DIP по типовому уровню). +func (p *GoParser) parseInterfaceEmbeds(iface *ast.InterfaceType) (embeds []string, methodSigs []InterfaceMethodSig) { if iface.Methods == nil { - return embeds + return embeds, methodSigs } for _, method := range iface.Methods.List { if len(method.Names) == 0 { typeName, _ := p.getTypeName(method.Type) embeds = append(embeds, typeName) + + continue + } + + // method.Type для метода интерфейса — *ast.FuncType (та же сигнатура). + ft, _ := method.Type.(*ast.FuncType) + params, results := p.parseSignature(ft) + + for _, name := range method.Names { + methodSigs = append(methodSigs, InterfaceMethodSig{Name: name.Name, Params: params, Results: results}) } } - return embeds + return embeds, methodSigs } // getTypeName извлекает имя типа из AST выражения. @@ -190,12 +212,132 @@ func (p *GoParser) getTypeName(expr ast.Expr) (typeName, typePkg string) { return "", "" } +// parseSignature извлекает type-refs параметров и возвратов из сигнатуры (Фаза 1). +// Имя параметра не важно для type-ref. Нерезолвимые/примитивы отсеются позже в +// билдере (resolveTypeDependency) -> ребро только на известный тип (соундность). +func (p *GoParser) parseSignature(ft *ast.FuncType) (params, results []FieldInfo) { + collect := func(list *ast.FieldList) []FieldInfo { + var out []FieldInfo + if list == nil { + return out + } + + for _, f := range list.List { + typeName, typePkg := p.getTypeName(f.Type) + if typeName == "" { + continue + } + + out = append(out, FieldInfo{TypeName: typeName, TypePkg: typePkg}) + } + + return out + } + + if ft == nil { + return params, results + } + + return collect(ft.Params), collect(ft.Results) +} + +// collectPackageLevelRefs собирает function-value-use из package-level var/const +// инициализаторов (вложенные composite-literals/slices/maps раскрываются ast.Inspect) +// и складывает в p.pkgRefs[pkgID]. Билдер атрибутирует их узлу .init (var-init +// выполняется при загрузке пакета ДО main; если пакет активен — ссылки живые), а +// при отсутствии init — package-узлу. Это (а)-фикс: видимый синтаксис регистрации +// (cobra RunE и т.п.) -> достижимость, БЕЗ config-whitelist (полнота сохраняется). +func (p *GoParser) collectPackageLevelRefs(d *ast.GenDecl, pkgID string) { + if p.pkgRefs == nil { + return + } + + for _, spec := range d.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + + for _, val := range vs.Values { + if refs := p.collectValueRefs(val); len(refs) > 0 { + p.pkgRefs[pkgID] = append(p.pkgRefs[pkgID], refs...) + } + } + } +} + +// collectFuncRefs собирает использования функции/метода как ЗНАЧЕНИЯ (callback) в +// ТЕЛЕ функции: Ident/SelectorExpr ВНЕ call-позиции. Принцип направления ошибки: +// пропуск ссылки = ложно-мёртвый код (дорого) -> собираем щедро; лишнее отсеется +// over-approx-резолвом по имени (ложно-живой = дёшево). +func (p *GoParser) collectFuncRefs(body *ast.BlockStmt) []CallInfo { + if body == nil { + return nil + } + + return p.collectValueRefs(body) +} + +// collectValueRefs — общий сбор function-value-use по ПРОИЗВОЛЬНОМУ узлу AST +// (тело функции ИЛИ инициализатор package-level var/const). Используется и для +// (а)-фикса cobra: `var c = &cobra.Command{RunE: H}` -> H собран отсюда. +func (p *GoParser) collectValueRefs(root ast.Node) []CallInfo { + if root == nil { + return nil + } + + callFuns := make(map[ast.Expr]bool) + + ast.Inspect(root, func(n ast.Node) bool { + switch s := n.(type) { + case *ast.CallExpr: + callFuns[s.Fun] = true + case *ast.GoStmt: + if s.Call != nil { + callFuns[s.Call.Fun] = true + } + case *ast.DeferStmt: + if s.Call != nil { + callFuns[s.Call.Fun] = true + } + } + + return true + }) + + var refs []CallInfo + + ast.Inspect(root, func(n ast.Node) bool { + expr, ok := n.(ast.Expr) + if !ok || callFuns[expr] { + return true // не выражение ИЛИ это call-fun (вызов, не значение) + } + + switch x := expr.(type) { + case *ast.SelectorExpr: + if id, ok := x.X.(*ast.Ident); ok { + refs = append(refs, CallInfo{Target: id.Name + "." + x.Sel.Name, IsMethod: true, Receiver: id.Name}) + } + + return false // не спускаемся в X/Sel (иначе двойной учёт) + case *ast.Ident: + refs = append(refs, CallInfo{Target: x.Name}) + } + + return true + }) + + return refs +} + // parseFuncDecl парсит объявления функций и методов. func (p *GoParser) parseFuncDecl(decl *ast.FuncDecl, pkgID, filename string, fset *token.FileSet) { funcName := decl.Name.Name pos := fset.Position(decl.Pos()) calls := p.collectCalls(decl.Body, pkgID, fset) + params, results := p.parseSignature(decl.Type) + refs := p.collectFuncRefs(decl.Body) if decl.Recv != nil && len(decl.Recv.List) > 0 { receiverType := p.getReceiverType(decl.Recv.List[0].Type) @@ -219,6 +361,9 @@ func (p *GoParser) parseFuncDecl(decl *ast.FuncDecl, pkgID, filename string, fse Line: pos.Line, Calls: calls, FieldAccess: fieldAccess, + Params: params, + Results: results, + Refs: refs, } } else { funcID := pkgID + "." + funcName @@ -229,6 +374,9 @@ func (p *GoParser) parseFuncDecl(decl *ast.FuncDecl, pkgID, filename string, fse File: filename, Line: pos.Line, Calls: calls, + Params: params, + Results: results, + Refs: refs, } } } diff --git a/internal/analyzer/iface_signature_golden_test.go b/internal/analyzer/iface_signature_golden_test.go new file mode 100644 index 00000000..90fea035 --- /dev/null +++ b/internal/analyzer/iface_signature_golden_test.go @@ -0,0 +1,71 @@ +package analyzer + +import ( + "testing" + + "github.com/mshogin/archlint/internal/model" +) + +// golden: сигнатуры методов ИНТЕРФЕЙСА -> рёбра usesType/returns ОТ интерфейса +// (DIP по типовому уровню). До правки от интерфейсов было 0 таких рёбер. + +func ifaceWithSig(name string, sigs ...model.InterfaceMethodSig) *TypeInfo { + return &TypeInfo{Name: name, Package: "p", Kind: "interface", MethodSigs: sigs} +} + +// (1) Метод интерфейса возвращает КОНКРЕТНЫЙ тип -> returns(I -> Concrete). +// Это и есть видимое DIP-нарушение (абстракция зависит от детали). +func TestIfaceSig_ReturnConcrete(t *testing.T) { + types := map[string]*TypeInfo{ + "p.Store": ifaceWithSig("Store", model.InterfaceMethodSig{ + Name: "Get", Results: []model.FieldInfo{{TypeName: "Record"}}}), + "p.Record": {Name: "Record", Package: "p", Kind: "struct"}, + } + edges := sigEdges(types, nil, nil) + if !hasEdge(edges, "p.Store", "p.Record", model.EdgeReturns) { + t.Fatalf("returns I-метод -> Concrete (DIP-нарушение по return) не материализован: %v", edges) + } +} + +// (2) Метод интерфейса принимает КОНКРЕТНЫЙ параметр -> usesType(I -> Concrete). +func TestIfaceSig_ParamConcrete(t *testing.T) { + types := map[string]*TypeInfo{ + "p.Sink": ifaceWithSig("Sink", model.InterfaceMethodSig{ + Name: "Put", Params: []model.FieldInfo{{TypeName: "Record"}}}), + "p.Record": {Name: "Record", Package: "p", Kind: "struct"}, + } + edges := sigEdges(types, nil, nil) + if !hasEdge(edges, "p.Sink", "p.Record", model.EdgeUses) { + t.Fatalf("usesType I-метод param-конкрет не материализован: %v", edges) + } +} + +// (3) СОУНДНОСТЬ: примитив/нерезолвимое в сигнатуре I-метода -> НЕ ложит +// ложного abstraction->detail ребра. +func TestIfaceSig_NoEdgeOnPrimitive(t *testing.T) { + types := map[string]*TypeInfo{ + "p.I": ifaceWithSig("I", model.InterfaceMethodSig{ + Name: "F", + Params: []model.FieldInfo{{TypeName: "int"}}, + Results: []model.FieldInfo{{TypeName: "string"}}}), + } + edges := sigEdges(types, nil, nil) + if len(edges) != 0 { + t.Fatalf("примитивы в сигнатуре I-метода не должны давать рёбер: %v", edges) + } +} + +// (4) Param-интерфейс (абстракция) -> ребро есть, но цель = интерфейс (не деталь); +// DIP-метрика отфильтрует по kind. Здесь проверяем что ребро ВООБЩЕ материализуется +// на резолвимый тип (полнота), а различение detail/abstraction — задача метрики. +func TestIfaceSig_ParamInterfaceResolves(t *testing.T) { + types := map[string]*TypeInfo{ + "p.A": ifaceWithSig("A", model.InterfaceMethodSig{ + Name: "Use", Params: []model.FieldInfo{{TypeName: "B"}}}), + "p.B": {Name: "B", Package: "p", Kind: "interface"}, + } + edges := sigEdges(types, nil, nil) + if !hasEdge(edges, "p.A", "p.B", model.EdgeUses) { + t.Fatalf("usesType I-метод param-интерфейса должен резолвиться (полнота): %v", edges) + } +} diff --git a/internal/analyzer/implements_golden_test.go b/internal/analyzer/implements_golden_test.go new file mode 100644 index 00000000..64a5a83c --- /dev/null +++ b/internal/analyzer/implements_golden_test.go @@ -0,0 +1,123 @@ +package analyzer + +import ( + "testing" + + "github.com/mshogin/archlint/internal/model" +) + +// golden для материализации implements-ребра (Фаза 1, ADR-0002). +// Особый акцент — ПОЛНОТА с embeds-промоушеном (struct/interface embedding). + +func implementsEdges(types map[string]*TypeInfo, methods map[string]*MethodInfo) []model.Edge { + var nodes []model.Node + var edges []model.Edge + g := newGoGraphBuilder(nil, types, nil, methods, &nodes, &edges) + g.buildImplementsEdges() + return edges +} + +func hasImpl(edges []model.Edge, from, to string) bool { + for _, e := range edges { + if e.Type == model.EdgeImplements && e.From == from && e.To == to { + return true + } + } + return false +} + +func iface(name string, methods ...string) *TypeInfo { + var sigs []model.InterfaceMethodSig + for _, m := range methods { + sigs = append(sigs, model.InterfaceMethodSig{Name: m}) + } + return &TypeInfo{Name: name, Package: "p", Kind: "interface", MethodSigs: sigs} +} +func strct(name string, embeds ...string) *TypeInfo { + return &TypeInfo{Name: name, Package: "p", Kind: "struct", Embeds: embeds} +} +func meth(recv, name string) *MethodInfo { + return &MethodInfo{Package: "p", Receiver: recv, Name: name} +} + +// (1) Прямая реализация: S.Foo удовлетворяет I{Foo}. +func TestImplements_Direct(t *testing.T) { + edges := implementsEdges( + map[string]*TypeInfo{"p.I": iface("I", "Foo"), "p.S": strct("S")}, + map[string]*MethodInfo{"p.S.Foo": meth("S", "Foo")}, + ) + if !hasImpl(edges, "p.S", "p.I") { + t.Fatalf("S должен реализовывать I; рёбра: %v", edges) + } +} + +// (2) КРИТИЧНЫЙ: embeds-промоушен struct->struct. Derived встроил Base (метод Foo), +// сам Foo не объявляет -> реализует I через промоушен. Неполнота здесь = Фаза 3 +// удалит живую реализацию. +func TestImplements_EmbedsPromotion_StructInStruct(t *testing.T) { + edges := implementsEdges( + map[string]*TypeInfo{ + "p.I": iface("I", "Foo"), + "p.Base": strct("Base"), + "p.Derived": strct("Derived", "Base"), + }, + map[string]*MethodInfo{"p.Base.Foo": meth("Base", "Foo")}, + ) + if !hasImpl(edges, "p.Derived", "p.I") { + t.Fatalf("Derived должен реализовывать I через промоушен Base.Foo; рёбра: %v", edges) + } + if !hasImpl(edges, "p.Base", "p.I") { + t.Fatalf("Base тоже реализует I") + } +} + +// (3) embeds-промоушен interface-in-struct: T встроил интерфейс E{Foo} -> промоутит Foo. +func TestImplements_EmbedsPromotion_InterfaceInStruct(t *testing.T) { + edges := implementsEdges( + map[string]*TypeInfo{ + "p.I": iface("I", "Foo"), + "p.E": iface("E", "Foo"), + "p.T": strct("T", "E"), + }, + map[string]*MethodInfo{}, + ) + if !hasImpl(edges, "p.T", "p.I") { + t.Fatalf("T должен реализовывать I через встроенный интерфейс E; рёбра: %v", edges) + } +} + +// (4) Интерфейс встраивает интерфейс: req(I)=I.Foo ∪ J.Bar. S с обоими -> implements I и J. +func TestImplements_InterfaceEmbedsInterface(t *testing.T) { + I := iface("I", "Foo") + I.Embeds = []string{"J"} + edges := implementsEdges( + map[string]*TypeInfo{"p.I": I, "p.J": iface("J", "Bar"), "p.S": strct("S")}, + map[string]*MethodInfo{"p.S.Foo": meth("S", "Foo"), "p.S.Bar": meth("S", "Bar")}, + ) + if !hasImpl(edges, "p.S", "p.I") { + t.Fatalf("S (Foo+Bar) должен реализовывать I(embeds J); рёбра: %v", edges) + } + if !hasImpl(edges, "p.S", "p.J") { + t.Fatalf("S должен реализовывать и J") + } +} + +// (5) Негатив: X без нужного метода НЕ реализует; пустой интерфейс не плодит рёбер. +func TestImplements_NoFalsePositive(t *testing.T) { + edges := implementsEdges( + map[string]*TypeInfo{ + "p.I": iface("I", "Foo"), + "p.X": strct("X"), + "p.Empty": iface("Empty"), // пустой интерфейс + }, + map[string]*MethodInfo{"p.X.Bar": meth("X", "Bar")}, // не Foo + ) + if hasImpl(edges, "p.X", "p.I") { + t.Fatalf("X (только Bar) НЕ должен реализовывать I{Foo}") + } + for _, e := range edges { + if e.To == "p.Empty" { + t.Fatalf("пустой интерфейс не должен плодить implements-рёбер: %v", e) + } + } +} diff --git a/internal/analyzer/references_golden_test.go b/internal/analyzer/references_golden_test.go new file mode 100644 index 00000000..8e55be83 --- /dev/null +++ b/internal/analyzer/references_golden_test.go @@ -0,0 +1,111 @@ +package analyzer + +import ( + "os" + "strings" + "testing" + + "github.com/mshogin/archlint/internal/model" +) + +// (5) Реальный парс: чистый вызов f() -> calls, НЕ references; value-use var h=f -> +// references. Проверяет синтаксический сбор (collectFuncRefs исключает call-позицию) +// + name-based линковку на настоящем коде. +func TestReferences_CallVsValue_RealParse(t *testing.T) { + src := `package p +func target() {} +func caller() { target() } +func user() { var h = target; _ = h } +` + dir := t.TempDir() + if err := os.WriteFile(dir+"/snippet.go", []byte(src), 0o600); err != nil { + t.Fatal(err) + } + g, err := NewGoAnalyzer().Analyze(dir) + if err != nil { + t.Fatal(err) + } + + edge := func(from, to, etype string) bool { + for _, e := range g.Edges { + if e.Type == etype && strings.HasSuffix(e.From, "."+from) && strings.HasSuffix(e.To, "."+to) { + return true + } + } + return false + } + + if !edge("caller", "target", "calls") { + t.Fatal("caller->target: ожидается calls-ребро") + } + if edge("caller", "target", model.EdgeReferences) { + t.Fatal("чистый вызов target() НЕ должен давать references (не дублируем calls)") + } + if !edge("user", "target", model.EdgeReferences) { + t.Fatal("value-use 'var h = target' должен давать references (F не мёртв)") + } +} + +// golden для references-ребра (Фаза 1): функция/метод как значение (callback). +// Принцип: резолв-фильтр оставляет только реальные функции (соундность таргета), +// но собираем щедро (полнота — пропуск ссылки = ложно-мёртвый код, дорого). + +func refEdges(funcs map[string]*FunctionInfo, methods map[string]*MethodInfo) []model.Edge { + var nodes []model.Node + var edges []model.Edge + g := newGoGraphBuilder(nil, nil, funcs, methods, &nodes, &edges) + g.buildReferenceEdges() + return edges +} + +// (1) Функция передана как значение -> references-ребро на неё. +func TestReferences_FuncAsValue(t *testing.T) { + funcs := map[string]*FunctionInfo{ + "p.handler": {Name: "handler", Package: "p"}, + "p.register": {Name: "register", Package: "p", Refs: []model.CallInfo{{Target: "p.handler"}}}, + } + edges := refEdges(funcs, nil) + if !hasEdge(edges, "p.register", "p.handler", model.EdgeReferences) { + t.Fatalf("references на функцию-значение не материализован; рёбра: %v", edges) + } +} + +// (2) Метод как значение -> references на метод. +func TestReferences_MethodAsValue(t *testing.T) { + methods := map[string]*MethodInfo{"p.S.Do": {Name: "Do", Receiver: "S", Package: "p"}} + funcs := map[string]*FunctionInfo{ + "p.use": {Name: "use", Package: "p", Refs: []model.CallInfo{{Target: "p.S.Do"}}}, + } + edges := refEdges(funcs, methods) + if !hasEdge(edges, "p.use", "p.S.Do", model.EdgeReferences) { + t.Fatalf("references на метод-значение не материализован; рёбра: %v", edges) + } +} + +// (3) Имя НЕ совпадает ни с одной функцией/методом (локальная переменная) -> нет ребра. +func TestReferences_NoEdgeOnNonFunc(t *testing.T) { + funcs := map[string]*FunctionInfo{ + "p.f": {Name: "f", Package: "p", Refs: []model.CallInfo{{Target: "someLocalVar"}}}, + } + edges := refEdges(funcs, nil) + if len(edges) != 0 { + t.Fatalf("имя без совпадающей функции/метода не должно давать references: %v", edges) + } +} + +// (4) OVER-APPROXIMATION ПО ИМЕНИ: ссылка x.Do (имя Do) -> рёбра на ВСЕ методы Do +// (без var-типа не знаем какой именно -> на все = добавляем достижимость, ложно-мёртвый +// невозможен). Истинная over-approx (на все, не подмножество). +func TestReferences_OverApproxByName(t *testing.T) { + methods := map[string]*MethodInfo{ + "p.A.Do": {Name: "Do", Receiver: "A", Package: "p"}, + "p.B.Do": {Name: "Do", Receiver: "B", Package: "p"}, + } + funcs := map[string]*FunctionInfo{ + "p.use": {Name: "use", Package: "p", Refs: []model.CallInfo{{Target: "x.Do", IsMethod: true, Receiver: "x"}}}, + } + edges := refEdges(funcs, methods) + if !hasEdge(edges, "p.use", "p.A.Do", model.EdgeReferences) || !hasEdge(edges, "p.use", "p.B.Do", model.EdgeReferences) { + t.Fatalf("over-approx: x.Do должен дать рёбра на ВСЕ методы Do (A.Do и B.Do); %v", edges) + } +} diff --git a/internal/analyzer/rust.go b/internal/analyzer/rust.go index ab3cc2bb..98e83203 100644 --- a/internal/analyzer/rust.go +++ b/internal/analyzer/rust.go @@ -160,7 +160,7 @@ func (ra *RustAnalyzer) parseRustFile(path, srcDir string) error { if err != nil { return err } - defer file.Close() + defer file.Close() //nolint:errcheck // rust.go is frozen; close error intentionally ignored scanner := bufio.NewScanner(file) inBlockComment := false @@ -260,7 +260,7 @@ func (ra *RustAnalyzer) parseCargo(path string) error { if err != nil { return err } - defer file.Close() + defer file.Close() //nolint:errcheck // rust.go is frozen; close error intentionally ignored crate := &CrateInfo{Path: path} scanner := bufio.NewScanner(file) @@ -307,7 +307,7 @@ func (ra *RustAnalyzer) parseWorkspace(cargoPath, rootDir string) { if err != nil { return } - defer file.Close() + defer file.Close() //nolint:errcheck // rust.go is frozen; close error intentionally ignored scanner := bufio.NewScanner(file) inWorkspace := false @@ -511,7 +511,7 @@ func pathToModuleName(relPath string) string { name := strings.TrimSuffix(relPath, ".rs") name = strings.ReplaceAll(name, string(filepath.Separator), "::") // Remove mod suffix: auth::mod -> auth - if strings.HasSuffix(name, "::mod") { + if strings.HasSuffix(name, "::mod") { //nolint:staticcheck // rust.go is frozen; SA4017 false-positive or intended guard name = strings.TrimSuffix(name, "::mod") } return name diff --git a/internal/analyzer/signature_golden_test.go b/internal/analyzer/signature_golden_test.go new file mode 100644 index 00000000..7522ab84 --- /dev/null +++ b/internal/analyzer/signature_golden_test.go @@ -0,0 +1,94 @@ +package analyzer + +import ( + "testing" + + "github.com/mshogin/archlint/internal/model" +) + +// golden для signature-рёбер Фазы 1: usesType(param) + returns. Акцент: +// полнота DIP (param-тип интерфейса ловится) при соундности (примитив не ложит). + +func sigEdges(types map[string]*TypeInfo, funcs map[string]*FunctionInfo, methods map[string]*MethodInfo) []model.Edge { + var nodes []model.Node + var edges []model.Edge + g := newGoGraphBuilder(nil, types, funcs, methods, &nodes, &edges) + g.buildSignatureEdges() + return edges +} + +func hasEdge(edges []model.Edge, from, to, etype string) bool { + for _, e := range edges { + if e.From == from && e.To == to && e.Type == etype { + return true + } + } + return false +} + +func ref(typeName string) model.FieldInfo { return model.FieldInfo{TypeName: typeName} } + +// (1) usesType: метод с параметром известного типа -> ребро uses на тип. +// Особо — param ИНТЕРФЕЙСА (полнота DIP: param-нарушение видно). +func TestSignature_UsesType_ParamInterface(t *testing.T) { + types := map[string]*TypeInfo{ + "p.Reader": {Name: "Reader", Package: "p", Kind: "interface", MethodSigs: []model.InterfaceMethodSig{{Name: "Read"}}}, + "p.Svc": {Name: "Svc", Package: "p", Kind: "struct"}, + } + methods := map[string]*MethodInfo{ + // func (Svc) Handle(r Reader) -> param Reader (интерфейс) + "p.Svc.Handle": {Name: "Handle", Receiver: "Svc", Package: "p", Params: []model.FieldInfo{ref("Reader")}}, + } + edges := sigEdges(types, nil, methods) + if !hasEdge(edges, "p.Svc.Handle", "p.Reader", model.EdgeUses) { + t.Fatalf("usesType param-интерфейса не материализован (DIP-полнота); рёбра: %v", edges) + } +} + +// (2) returns: функция возвращает известный тип -> ребро returns. +func TestSignature_Returns(t *testing.T) { + types := map[string]*TypeInfo{"p.Config": {Name: "Config", Package: "p", Kind: "struct"}} + funcs := map[string]*FunctionInfo{ + // func NewConfig() Config + "p.NewConfig": {Name: "NewConfig", Package: "p", Results: []model.FieldInfo{ref("Config")}}, + } + edges := sigEdges(types, funcs, nil) + if !hasEdge(edges, "p.NewConfig", "p.Config", model.EdgeReturns) { + t.Fatalf("returns не материализован; рёбра: %v", edges) + } +} + +// (3) Соундность: примитивный/нерезолвимый param НЕ ложит ребро (ложный edge -> ложный DIP ERROR). +func TestSignature_NoEdgeOnPrimitive(t *testing.T) { + types := map[string]*TypeInfo{"p.Svc": {Name: "Svc", Package: "p", Kind: "struct"}} + methods := map[string]*MethodInfo{ + // func (Svc) F(n int) string -> int/string примитивы, чужой тип не известен + "p.Svc.F": {Name: "F", Receiver: "Svc", Package: "p", + Params: []model.FieldInfo{ref("int")}, + Results: []model.FieldInfo{ref("string")}}, + } + edges := sigEdges(types, nil, methods) + if len(edges) != 0 { + t.Fatalf("примитивы не должны давать signature-рёбер: %v", edges) + } +} + +// (4) usesType param конкретного типа + returns одновременно. +func TestSignature_ParamAndReturnConcrete(t *testing.T) { + types := map[string]*TypeInfo{ + "p.In": {Name: "In", Package: "p", Kind: "struct"}, + "p.Out": {Name: "Out", Package: "p", Kind: "struct"}, + } + funcs := map[string]*FunctionInfo{ + "p.Transform": {Name: "Transform", Package: "p", + Params: []model.FieldInfo{ref("In")}, + Results: []model.FieldInfo{ref("Out")}}, + } + edges := sigEdges(types, funcs, nil) + if !hasEdge(edges, "p.Transform", "p.In", model.EdgeUses) { + t.Fatal("usesType param In не пойман") + } + if !hasEdge(edges, "p.Transform", "p.Out", model.EdgeReturns) { + t.Fatal("returns Out не пойман") + } +} diff --git a/internal/analyzer/typescript.go b/internal/analyzer/typescript.go index d45ac6f2..31a9254e 100644 --- a/internal/analyzer/typescript.go +++ b/internal/analyzer/typescript.go @@ -7,7 +7,6 @@ package analyzer import ( - "bufio" "fmt" "os" "path/filepath" @@ -399,22 +398,6 @@ func (ta *TypeScriptAnalyzer) resolveImport(fromPkg, importSrc string) string { return "" } -// readFileLines is a helper used in tests and scanning for line-by-line analysis. -func readFileLines(path string) ([]string, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - - var lines []string - sc := bufio.NewScanner(f) - for sc.Scan() { - lines = append(lines, sc.Text()) - } - return lines, sc.Err() -} - // DetectTypeScriptProject returns true if dir contains a package.json or tsconfig.json, // or any .ts/.tsx source files. func DetectTypeScriptProject(dir string) bool { diff --git a/internal/analyzer/zz_phase1_selfcheck_test.go b/internal/analyzer/zz_phase1_selfcheck_test.go new file mode 100644 index 00000000..008150e9 --- /dev/null +++ b/internal/analyzer/zz_phase1_selfcheck_test.go @@ -0,0 +1,40 @@ +package analyzer + +import ( + "testing" + + "github.com/mshogin/archlint/internal/model" +) + +// Self-оракул Фазы 1 на archlint-на-себе (реальный граф internal/): implements-рёбра +// материализуются, type-узлы несут Attrs.kind. Полнота важнее — проверяем что НЕ ноль. +func TestPhase1_SelfOracle(t *testing.T) { + g, err := NewGoAnalyzer().Analyze("..") + if err != nil { + t.Fatalf("analyze internal/: %v", err) + } + var impl, kindAttr, ifaceNodes int + for _, e := range g.Edges { + if e.Type == model.EdgeImplements { + impl++ + } + } + for _, n := range g.Nodes { + if n.Attrs != nil { + if k, ok := n.Attrs["kind"]; ok { + kindAttr++ + if k == model.KindInterface { + ifaceNodes++ + } + } + } + } + t.Logf("nodes=%d edges=%d implements=%d kind-attr-nodes=%d interfaces=%d", + len(g.Nodes), len(g.Edges), impl, kindAttr, ifaceNodes) + if impl == 0 { + t.Fatal("0 implements-рёбер на реальном коде с интерфейсами — материализация не работает") + } + if kindAttr == 0 { + t.Fatal("ни один type-узел не несёт Attrs.kind") + } +} diff --git a/internal/archlintcfg/config.go b/internal/archlintcfg/config.go index 78677f12..17d7a1b7 100644 --- a/internal/archlintcfg/config.go +++ b/internal/archlintcfg/config.go @@ -104,6 +104,12 @@ type Config struct { // Additive on top of language-specific built-in defaults // (e.g. Go: vendor, node_modules, .git, bin). ExcludePaths []string `yaml:"exclude_paths,omitempty"` + // EntryPoints lists ADDITIONAL reachability roots (R) for dead-code (Phase 3), + // дополняющие авто-дефолт (main/init/Test*/exported). Для того, что авто не + // видит детерминированно: framework-хендлеры, символы через рефлексию/DI/codegen. + // Паттерн = подстрока, матчится против ID узла графа (pkg.Func / pkg.Type.Method). + // Пропущенный entry -> ложно-мёртвый код, поэтому это страховка полноты R. + EntryPoints []string `yaml:"entrypoints,omitempty"` } // Default thresholds matching archlint-rs defaults. diff --git a/internal/archmotifbridge/bridge.go b/internal/archmotifbridge/bridge.go new file mode 100644 index 00000000..51106998 --- /dev/null +++ b/internal/archmotifbridge/bridge.go @@ -0,0 +1,341 @@ +// Package archmotifbridge adapts archlint's architectural graph (internal/model) +// to archmotif's metric engine via the public github.com/mshogin/archmotif fork +// (pkg/archmotifimport.Builder -> pkg/archmotifmetrics.ComputeMetrics). It lets +// archlint reuse archmotif's research-grade metrics (Newman modularity Q, motif +// redundancy, anomaly detectors) WITHOUT GraphML or reflection — just an +// in-memory graph hand-off. +// +// A runtime fallback (variant A from the kgatilin-reflex-archmotif research: +// own modularity over the package import graph) keeps archlint working if the +// archmotif provider fails for any reason, so callers always get a Report. +package archmotifbridge + +import ( + "errors" + "fmt" + "sort" + + "github.com/kgatilin/archmotif/pkg/archmotifimport" + "github.com/kgatilin/archmotif/pkg/archmotifmetrics" + "github.com/mshogin/archlint/internal/model" +) + +var errNilGraph = errors.New("archmotifbridge: nil graph") + +func msgf(format string, args ...any) string { return fmt.Sprintf(format, args...) } + +// Anomaly is archlint's view of one archmotif-flagged region. +type Anomaly struct { + Metric string + Code string + Message string + Score float64 + PrimaryID string +} + +// Report is the provider-agnostic metric result. Source identifies which +// provider produced it ("archmotif" or "fallback") so a caller can label +// degradation output honestly. +type Report struct { + Source string + Modularity float64 + HasModularity bool + GraphMetrics map[string]float64 + Anomalies []Anomaly + Notes []string // mapping diagnostics: skipped nodes/edges, fallback reason +} + +// MetricsProvider computes a Report from an archlint graph. +type MetricsProvider interface { + Name() string + Compute(g *model.Graph) (Report, error) +} + +// Compute runs the archmotif provider and, on any error, falls back to the +// built-in provider. This is the entry point archlint callers should use. +func Compute(g *model.Graph) Report { + primary := ArchmotifProvider{} + rep, err := primary.Compute(g) + if err == nil { + return rep + } + fb := FallbackProvider{} + frep, ferr := fb.Compute(g) + frep.Notes = append([]string{"archmotif provider unavailable: " + err.Error()}, frep.Notes...) + if ferr != nil { + frep.Notes = append(frep.Notes, "fallback also failed: "+ferr.Error()) + } + return frep +} + +// --------------------------------------------------------------------------- +// archmotif provider: model.Graph -> archmotifimport.Builder -> ComputeMetrics +// --------------------------------------------------------------------------- + +// ArchmotifProvider computes metrics through the archmotif fork. +type ArchmotifProvider struct{} + +func (ArchmotifProvider) Name() string { return "archmotif" } + +// edgeKindMap maps archlint edge types to archmotif dependency kinds. Structural +// "contains" is handled by Builder's Add* parenting; field_read/field_write are +// dropped (archmotif's vocabulary stops at the symbol level for these). +var edgeKindMap = map[string]archmotifimport.DependencyKind{ + model.EdgeImport: archmotifimport.DependencyDependsOn, + model.EdgeCalls: archmotifimport.DependencyCalls, + model.EdgeUses: archmotifimport.DependencyReferences, + model.EdgeEmbeds: archmotifimport.DependencyEmbeds, +} + +func (ArchmotifProvider) Compute(g *model.Graph) (Report, error) { + rep := Report{Source: "archmotif", GraphMetrics: map[string]float64{}} + if g == nil { + return rep, errNilGraph + } + + b := archmotifimport.NewBuilder() + + // Index nodes by ID and resolve structural parents from `contains` edges + // (From = parent, To = child). + entity := make(map[string]string, len(g.Nodes)) + for _, n := range g.Nodes { + entity[n.ID] = n.Entity + } + parent := make(map[string]string) + for _, e := range g.Edges { + if e.Type == model.EdgeContains { + parent[e.To] = e.From + } + } + + added := make(map[string]bool, len(g.Nodes)) + skip := 0 + note := func() { skip++ } + + // Tier 1: packages and external packages. + for _, n := range g.Nodes { + if n.Entity == model.EntityPackage || n.Entity == model.EntityExternal { + if err := b.AddPackage(n.ID, "", ""); err != nil { + note() + continue + } + added[n.ID] = true + } + } + // Tier 2: types (struct/interface) under their package. + for _, n := range g.Nodes { + if n.Entity != model.EntityStruct && n.Entity != model.EntityInterface { + continue + } + pkg := parent[n.ID] + if !added[pkg] { + note() + continue + } + if err := b.AddType(n.ID, pkg, n.Entity == model.EntityInterface, ""); err != nil { + note() + continue + } + added[n.ID] = true + } + // Tier 3: functions under their package. + for _, n := range g.Nodes { + if n.Entity != model.EntityFunction { + continue + } + pkg := parent[n.ID] + if !added[pkg] { + note() + continue + } + if err := b.AddFunction(n.ID, pkg); err != nil { + note() + continue + } + added[n.ID] = true + } + // Tier 4: methods under their receiver type. + for _, n := range g.Nodes { + if n.Entity != model.EntityMethod { + continue + } + typ := parent[n.ID] + if !added[typ] { + note() + continue + } + if err := b.AddMethod(n.ID, typ); err != nil { + note() + continue + } + added[n.ID] = true + } + // Tier 5: fields under their struct. + for _, n := range g.Nodes { + if n.Entity != model.EntityField { + continue + } + st := parent[n.ID] + if !added[st] { + note() + continue + } + if err := b.AddField(n.ID, st, ""); err != nil { + note() + continue + } + added[n.ID] = true + } + + // Edges: map non-structural edges; skip when an endpoint was not added. + edgeSkip := 0 + for _, e := range g.Edges { + kind, ok := edgeKindMap[e.Type] + if !ok { + continue // contains / field_read / field_write + } + if !added[e.From] || !added[e.To] { + edgeSkip++ + continue + } + if err := b.AddDependency(e.From, e.To, kind); err != nil { + edgeSkip++ + } + } + + g2, err := b.Build() + if err != nil { + return rep, err + } + m, err := archmotifmetrics.ComputeMetrics(g2) + if err != nil { + return rep, err + } + + rep.Modularity = m.Modularity + rep.HasModularity = m.HasModularity + for _, gmv := range m.Graph { + rep.GraphMetrics[gmv.Metric] = gmv.Value + } + for _, a := range m.Anomalies { + rep.Anomalies = append(rep.Anomalies, Anomaly{ + Metric: a.Metric, Code: a.Code, Message: a.Message, + Score: a.Score, PrimaryID: a.PrimaryID, + }) + } + if skip > 0 { + rep.Notes = append(rep.Notes, msgf("skipped %d node(s) with missing parent", skip)) + } + if edgeSkip > 0 { + rep.Notes = append(rep.Notes, msgf("skipped %d edge(s) with unmapped endpoint", edgeSkip)) + } + rep.Notes = append(rep.Notes, msgf("archmotif metricsRan=%v detectorsRan=%v", m.MetricsRan, m.DetectorsRan)) + return rep, nil +} + +// --------------------------------------------------------------------------- +// fallback provider: own Newman modularity over the package import graph +// (variant A seed — keeps archlint self-sufficient if archmotif is absent) +// --------------------------------------------------------------------------- + +// FallbackProvider computes a built-in modularity over package import edges via +// label-propagation communities. Deliberately small and dependency-free. +type FallbackProvider struct{} + +func (FallbackProvider) Name() string { return "fallback" } + +func (FallbackProvider) Compute(g *model.Graph) (Report, error) { + rep := Report{Source: "fallback", GraphMetrics: map[string]float64{}} + if g == nil { + return rep, errNilGraph + } + // Undirected package adjacency from import edges between package nodes. + isPkg := map[string]bool{} + for _, n := range g.Nodes { + if n.Entity == model.EntityPackage || n.Entity == model.EntityExternal { + isPkg[n.ID] = true + } + } + adj := map[string]map[string]float64{} + deg := map[string]float64{} + var m2 float64 // 2x total edge weight + addEdge := func(a, c string) { + if a == c { + return + } + if adj[a] == nil { + adj[a] = map[string]float64{} + } + if adj[c] == nil { + adj[c] = map[string]float64{} + } + adj[a][c]++ + adj[c][a]++ + deg[a]++ + deg[c]++ + m2 += 2 + } + for _, e := range g.Edges { + if e.Type == model.EdgeImport && isPkg[e.From] && isPkg[e.To] { + addEdge(e.From, e.To) + } + } + if m2 == 0 { + rep.Notes = append(rep.Notes, "no package import edges; modularity undefined") + return rep, nil + } + + // Label propagation: start each node in its own community, iterate to a + // fixed point (bounded), assigning each node the most-weighted neighbour label. + comm := map[string]string{} + nodes := make([]string, 0, len(deg)) + for n := range deg { + comm[n] = n + nodes = append(nodes, n) + } + sort.Strings(nodes) // determinism + for iter := 0; iter < 20; iter++ { + changed := false + for _, n := range nodes { + best, bestW := comm[n], -1.0 + tally := map[string]float64{} + for nb, w := range adj[n] { + tally[comm[nb]] += w + } + labels := make([]string, 0, len(tally)) + for l := range tally { + labels = append(labels, l) + } + sort.Strings(labels) + for _, l := range labels { + if tally[l] > bestW { + bestW, best = tally[l], l + } + } + if best != comm[n] { + comm[n] = best + changed = true + } + } + if !changed { + break + } + } + + // Newman Q = sum_ij [ A_ij/2m - (k_i k_j)/(2m)^2 ] * delta(c_i,c_j). + var q float64 + for _, i := range nodes { + for _, j := range nodes { + if comm[i] != comm[j] { + continue + } + a := adj[i][j] + q += a/m2 - (deg[i]*deg[j])/(m2*m2) + } + } + rep.Modularity = q + rep.HasModularity = true + rep.GraphMetrics["modularity"] = q + rep.Notes = append(rep.Notes, "fallback modularity via label-propagation communities") + return rep, nil +} diff --git a/internal/archmotifbridge/bridge_test.go b/internal/archmotifbridge/bridge_test.go new file mode 100644 index 00000000..2c38a449 --- /dev/null +++ b/internal/archmotifbridge/bridge_test.go @@ -0,0 +1,80 @@ +package archmotifbridge_test + +import ( + "testing" + + "github.com/mshogin/archlint/internal/analyzer" + "github.com/mshogin/archlint/internal/archmotifbridge" + "github.com/mshogin/archlint/internal/model" +) + +// fixtureGraph builds a 4-package archlint graph with two import-communities +// ({a,b} and {c,d}) joined by a single bridge import b->c. +func fixtureGraph() *model.Graph { + pkg := func(id string) model.Node { return model.Node{ID: id, Title: id, Entity: model.EntityPackage} } + imp := func(a, b string) model.Edge { return model.Edge{From: a, To: b, Type: model.EdgeImport} } + return &model.Graph{ + Nodes: []model.Node{pkg("a"), pkg("b"), pkg("c"), pkg("d")}, + Edges: []model.Edge{ + imp("a", "b"), imp("b", "a"), + imp("c", "d"), imp("d", "c"), + imp("b", "c"), + }, + } +} + +func TestArchmotifProvider_Fixture(t *testing.T) { + rep, err := archmotifbridge.ArchmotifProvider{}.Compute(fixtureGraph()) + if err != nil { + t.Fatalf("archmotif Compute: %v", err) + } + if rep.Source != "archmotif" { + t.Fatalf("source = %q, want archmotif", rep.Source) + } + if !rep.HasModularity { + t.Fatalf("modularity not computed; notes=%v", rep.Notes) + } + t.Logf("archmotif: Q=%.4f graphMetrics=%v anomalies=%d notes=%v", + rep.Modularity, rep.GraphMetrics, len(rep.Anomalies), rep.Notes) +} + +func TestFallbackProvider_Fixture(t *testing.T) { + rep, err := archmotifbridge.FallbackProvider{}.Compute(fixtureGraph()) + if err != nil { + t.Fatalf("fallback Compute: %v", err) + } + if rep.Source != "fallback" || !rep.HasModularity { + t.Fatalf("fallback bad report: %+v", rep) + } + // Two clear communities -> positive modularity. + if rep.Modularity <= 0 { + t.Fatalf("fallback Q=%.4f, want >0 for two communities", rep.Modularity) + } + t.Logf("fallback: Q=%.4f notes=%v", rep.Modularity, rep.Notes) +} + +func TestCompute_NilGraphFallsBack(t *testing.T) { + rep := archmotifbridge.Compute(nil) + if rep.Source != "fallback" { + t.Fatalf("nil graph should fall back, got source=%q", rep.Source) + } +} + +// TestCompute_RealGraph proves the end-to-end path on a REAL project graph: +// archlint analyzes its own internal/model package, then the bridge computes +// archmotif metrics over it. +func TestCompute_RealGraph(t *testing.T) { + g, err := analyzer.NewGoAnalyzer().Analyze("../model") + if err != nil { + t.Fatalf("analyze real project: %v", err) + } + if len(g.Nodes) == 0 { + t.Skip("real graph empty (analyzer produced no nodes)") + } + rep := archmotifbridge.Compute(g) + t.Logf("REAL graph: nodes=%d edges=%d -> source=%s Q=%.4f hasMod=%v graphMetrics=%v anomalies=%d", + len(g.Nodes), len(g.Edges), rep.Source, rep.Modularity, rep.HasModularity, rep.GraphMetrics, len(rep.Anomalies)) + for _, n := range rep.Notes { + t.Logf(" note: %s", n) + } +} diff --git a/internal/bot/github.go b/internal/bot/github.go index e6e41b88..46906970 100644 --- a/internal/bot/github.go +++ b/internal/bot/github.go @@ -67,7 +67,7 @@ func (c *HTTPGitHubClient) ListOpenIssues(ctx context.Context, owner, repo strin if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("github list issues: status %d", resp.StatusCode) @@ -109,7 +109,7 @@ func (c *HTTPGitHubClient) PostComment(ctx context.Context, owner, repo string, if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { return fmt.Errorf("github post comment: status %d", resp.StatusCode) } @@ -124,7 +124,7 @@ func (c *HTTPGitHubClient) CloseIssue(ctx context.Context, owner, repo string, n if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("github close issue: status %d", resp.StatusCode) } diff --git a/internal/bot/scanner.go b/internal/bot/scanner.go index 18b761d0..b748b137 100644 --- a/internal/bot/scanner.go +++ b/internal/bot/scanner.go @@ -49,7 +49,7 @@ func (s *LocalScanner) Scan(ctx context.Context, repoURL string) (*ScanResult, e if err != nil { return nil, fmt.Errorf("create temp dir: %w", err) } - defer os.RemoveAll(tmpDir) + defer func() { _ = os.RemoveAll(tmpDir) }() cloneDir := filepath.Join(tmpDir, "repo") if err := gitClone(ctx, repoURL, cloneDir); err != nil { diff --git a/internal/cli/baseline.go b/internal/cli/baseline.go new file mode 100644 index 00000000..640ab02d --- /dev/null +++ b/internal/cli/baseline.go @@ -0,0 +1,111 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/mshogin/archlint/internal/archlintcfg" + "github.com/mshogin/archlint/internal/mcp" + "github.com/spf13/cobra" +) + +var ( + baselineOutputFile string + baselineConfigFile string + baselineExclude []string +) + +var baselineCmd = &cobra.Command{ + Use: "baseline [directory]", + Short: "Snapshot current ERROR-class architecture patterns for the delta gate", + Long: `Build a baseline snapshot of the code's current ERROR-class pattern facts +(SCC cycles, layer back-edges, dead code) into .archlint-baseline.json. + +The delta gate (archlint scan) compares live patterns against this baseline and +blocks only NEW regressions, not pre-existing (legacy) violations. Without a +baseline file scan degrades to audit (telemetry, no block). + +The snapshot is deterministic: two runs over identical code are byte-identical +(sorted, stable keys). Commit it to lock in the current architecture as the floor. + +Examples: + archlint baseline . + archlint baseline ./internal -o ./internal/.archlint-baseline.json`, + Args: cobra.ExactArgs(1), + RunE: runBaseline, +} + +func init() { + baselineCmd.Flags().StringVarP(&baselineOutputFile, "output", "o", "", "Output path (default: /.archlint-baseline.json)") + baselineCmd.Flags().StringVar(&baselineConfigFile, "config", "", "Path to .archlint.yaml config file (default: /.archlint.yaml)") + baselineCmd.Flags().StringSliceVar(&baselineExclude, "exclude", nil, "Directory basenames to skip during the source walk (additive). Repeatable.") + rootCmd.AddCommand(baselineCmd) +} + +func runBaseline(_ *cobra.Command, args []string) error { + codeDir := args[0] + + var cfg archlintcfg.Config + if baselineConfigFile != "" { + cfg = archlintcfg.LoadFile(baselineConfigFile) + } else { + absDir, err := filepath.Abs(codeDir) + if err != nil { + absDir = codeDir + } + cfg = archlintcfg.Load(absDir) + } + + excludes := mergeExcludes(cfg.ExcludePaths, baselineExclude) + + graph, a, err := analyzeForGate(codeDir, excludes) + if err != nil { + return err + } + + violations := errorClassViolations(graph, a, &cfg) + baseline := mcp.BuildBaseline(violations) + + data, err := json.MarshalIndent(baseline, "", " ") + if err != nil { + return fmt.Errorf("baseline serialization error: %w", err) + } + data = append(data, '\n') + + outPath := baselineOutputFile + if outPath == "" { + outPath = filepath.Join(codeDir, defaultBaselineName) + } + + //nolint:gosec // G304: outPath is a user-provided CLI argument + if err := os.WriteFile(outPath, data, 0o644); err != nil { + return fmt.Errorf("failed to write baseline %s: %w", outPath, err) + } + + total := 0 + for _, fps := range baseline.Patterns { + total += len(fps) + } + fmt.Fprintf(os.Stderr, "baseline written: %s (%d ERROR-class patterns across %d kinds)\n", outPath, total, len(baseline.Patterns)) + + return nil +} + +// loadBaseline читает снимок дельта-гейта. Отсутствие файла -> (nil, nil): гейт +// деградирует в audit (no-baseline -> no-block, DR-0034 п.2), это НЕ ошибка. +func loadBaseline(path string) (*mcp.Baseline, error) { + data, err := os.ReadFile(path) //nolint:gosec // G304: path derived from scanned dir / --baseline flag + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read baseline %s: %w", path, err) + } + var b mcp.Baseline + if err := json.Unmarshal(data, &b); err != nil { + return nil, fmt.Errorf("failed to parse baseline %s: %w", path, err) + } + return &b, nil +} diff --git a/internal/cli/batch.go b/internal/cli/batch.go index cc1567bb..8d7b8550 100644 --- a/internal/cli/batch.go +++ b/internal/cli/batch.go @@ -297,7 +297,7 @@ func runBatch(cmd *cobra.Command, args []string) error { // Scan each directory. results := make([]batchRepoResult, 0, len(dirs)) for _, d := range dirs { - fmt.Fprintf(os.Stderr, "scanning %s...\n", d) + _, _ = fmt.Fprintf(os.Stderr, "scanning %s...\n", d) var repoCfg *archlintcfg.Config if cfg != nil { repoCfg = cfg @@ -326,7 +326,7 @@ func runBatch(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("cannot open output file: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() out = f } @@ -348,37 +348,37 @@ func runBatch(cmd *cobra.Command, args []string) error { } func writeBatchMarkdown(out *os.File, report batchReport) error { - fmt.Fprintf(out, "# Architecture Health Report\n\n") - fmt.Fprintf(out, "Total repos: %d | Scanned OK: %d | Errors: %d | Avg health: %d/100\n\n", + _, _ = fmt.Fprintf(out, "# Architecture Health Report\n\n") + _, _ = fmt.Fprintf(out, "Total repos: %d | Scanned OK: %d | Errors: %d | Avg health: %d/100\n\n", report.TotalRepos, report.ScannedOK, report.Errors, report.AvgHealth) // Table header. - fmt.Fprintf(out, "| Repository | Violations | SOLID | God-class | Fan-out | Cycles | Feature-envy | Coupling | Health |\n") - fmt.Fprintf(out, "|------------|-----------|-------|-----------|---------|--------|-------------|----------|--------|\n") + _, _ = fmt.Fprintf(out, "| Repository | Violations | SOLID | God-class | Fan-out | Cycles | Feature-envy | Coupling | Health |\n") + _, _ = fmt.Fprintf(out, "|------------|-----------|-------|-----------|---------|--------|-------------|----------|--------|\n") for _, r := range report.Results { if r.Error != "" { - fmt.Fprintf(out, "| %s | ERROR | - | - | - | - | - | - | - |\n", r.Repository) + _, _ = fmt.Fprintf(out, "| %s | ERROR | - | - | - | - | - | - | - |\n", r.Repository) continue } - fmt.Fprintf(out, "| %s | %d | %d | %d | %d | %d | %d | %d | %d/100 |\n", + _, _ = fmt.Fprintf(out, "| %s | %d | %d | %d | %d | %d | %d | %d | %d/100 |\n", r.Repository, r.Violations, r.SOLID, r.GodClass, r.FanOut, r.Cycles, r.FeatureEnvy, r.Coupling, r.Health) } // Summary section. - fmt.Fprintf(out, "\n## Summary\n\n") - fmt.Fprintf(out, "- Total repositories scanned: %d\n", report.ScannedOK) - fmt.Fprintf(out, "- Average health score: %d/100\n", report.AvgHealth) + _, _ = fmt.Fprintf(out, "\n## Summary\n\n") + _, _ = fmt.Fprintf(out, "- Total repositories scanned: %d\n", report.ScannedOK) + _, _ = fmt.Fprintf(out, "- Average health score: %d/100\n", report.AvgHealth) if len(report.Worst5) > 0 { - fmt.Fprintf(out, "- Worst repos (most violations): %s\n", strings.Join(report.Worst5, ", ")) + _, _ = fmt.Fprintf(out, "- Worst repos (most violations): %s\n", strings.Join(report.Worst5, ", ")) } if report.Errors > 0 { - fmt.Fprintf(out, "\n### Scan errors\n\n") + _, _ = fmt.Fprintf(out, "\n### Scan errors\n\n") for _, r := range report.Results { if r.Error != "" { - fmt.Fprintf(out, "- %s: %s\n", r.Repository, r.Error) + _, _ = fmt.Fprintf(out, "- %s: %s\n", r.Repository, r.Error) } } } diff --git a/internal/cli/batch_test.go b/internal/cli/batch_test.go index ff8a5090..34423347 100644 --- a/internal/cli/batch_test.go +++ b/internal/cli/batch_test.go @@ -40,7 +40,7 @@ func TestDirHasGoFiles(t *testing.T) { if err != nil { t.Fatal(err) } - f.Close() + _ = f.Close() if !dirHasGoFiles(dir) { t.Error("expected dirHasGoFiles=true for dir with main.go") @@ -91,7 +91,7 @@ func TestCollectDirsExplicitList(t *testing.T) { // Both have go files so single-arg parent-detection is skipped. for _, d := range []string{dir1, dir2} { f, _ := os.Create(filepath.Join(d, "a.go")) - f.Close() + _ = f.Close() } // Multiple args: treated as explicit list. @@ -169,10 +169,10 @@ func TestWriteBatchMarkdown(t *testing.T) { if err := writeBatchMarkdown(w, report); err != nil { t.Fatalf("writeBatchMarkdown error: %v", err) } - w.Close() + _ = w.Close() var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) out := buf.String() if !strings.Contains(out, "# Architecture Health Report") { @@ -199,10 +199,10 @@ func TestWriteBatchCSV(t *testing.T) { if err := writeBatchCSV(w, report); err != nil { t.Fatalf("writeBatchCSV error: %v", err) } - w.Close() + _ = w.Close() var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) csvR := csv.NewReader(&buf) records, err := csvR.ReadAll() @@ -252,11 +252,11 @@ func TestRunBatchJSONOnSelf(t *testing.T) { runErr := runBatch(nil, []string{dir}) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) output := buf.String() if runErr != nil { diff --git a/internal/cli/collect.go b/internal/cli/collect.go index b69c5e7e..a15b141b 100644 --- a/internal/cli/collect.go +++ b/internal/cli/collect.go @@ -66,7 +66,7 @@ func runCollect(cmd *cobra.Command, args []string) error { statusOut = os.Stderr } - fmt.Fprintf(statusOut, "Analyzing code: %s (language: %s)\n", codeDir, collectLanguage) + _, _ = fmt.Fprintf(statusOut, "Analyzing code: %s (language: %s)\n", codeDir, collectLanguage) graph, err := analyzeCode(codeDir) if err != nil { @@ -80,7 +80,7 @@ func runCollect(cmd *cobra.Command, args []string) error { } if collectOutputFile != "-" { - fmt.Fprintf(statusOut, "Graph saved to %s\n", collectOutputFile) + _, _ = fmt.Fprintf(statusOut, "Graph saved to %s\n", collectOutputFile) } return nil @@ -122,10 +122,6 @@ func analyzeCode(codeDir string) (*model.Graph, error) { } } -func printStats(graph *model.Graph) { - printStatsTo(graph, os.Stdout) -} - func printStatsTo(graph *model.Graph, w *os.File) { stats := make(map[string]int) @@ -133,13 +129,13 @@ func printStatsTo(graph *model.Graph, w *os.File) { stats[node.Entity]++ } - fmt.Fprintf(w, "Found components: %d\n", len(graph.Nodes)) + _, _ = fmt.Fprintf(w, "Found components: %d\n", len(graph.Nodes)) for entity, count := range stats { - fmt.Fprintf(w, " - %s: %d\n", entity, count) + _, _ = fmt.Fprintf(w, " - %s: %d\n", entity, count) } - fmt.Fprintf(w, "Found edges: %d\n", len(graph.Edges)) + _, _ = fmt.Fprintf(w, "Found edges: %d\n", len(graph.Edges)) } func saveGraph(graph *model.Graph) error { diff --git a/internal/cli/gate.go b/internal/cli/gate.go new file mode 100644 index 00000000..ce84d40d --- /dev/null +++ b/internal/cli/gate.go @@ -0,0 +1,57 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/mshogin/archlint/internal/analyzer" + "github.com/mshogin/archlint/internal/archlintcfg" + "github.com/mshogin/archlint/internal/mcp" + "github.com/mshogin/archlint/internal/model" +) + +// defaultBaselineName — имя файла снимка дельта-гейта рядом со сканируемым кодом. +const defaultBaselineName = ".archlint-baseline.json" + +// analyzeForGate строит граф для гейт-команд (baseline/scan-delta). Возвращает +// граф и Go-анализатор (nil для TS/Rust — у них нет dead-code/FileMetrics-фактов). +func analyzeForGate(codeDir string, excludes []string) (*model.Graph, *analyzer.GoAnalyzer, error) { + if _, err := os.Stat(codeDir); os.IsNotExist(err) { + return nil, nil, fmt.Errorf("%w: %s", errDirNotExist, codeDir) + } + + switch { + case analyzer.DetectRustProject(codeDir): + g, err := analyzer.NewRustAnalyzer().WithExcludeDirs(excludes).Analyze(codeDir) + if err != nil { + return nil, nil, fmt.Errorf("analysis error: %w", err) + } + return g, nil, nil + case analyzer.DetectTypeScriptProject(codeDir): + g, err := analyzer.NewTypeScriptAnalyzer().WithExcludeDirs(excludes).Analyze(codeDir) + if err != nil { + return nil, nil, fmt.Errorf("analysis error: %w", err) + } + return g, nil, nil + default: + a := analyzer.NewGoAnalyzer().WithExcludeDirs(excludes) + g, err := a.Analyze(codeDir) + if err != nil { + return nil, nil, fmt.Errorf("analysis error: %w", err) + } + return g, a, nil + } +} + +// errorClassViolations собирает ВСЕ паттерн-факты, которые участвуют в дельта-гейте: +// структурные (cycles, layer back-edges) + dead-code (только Go-граф). BuildBaseline +// сам отфильтрует ERROR-class, но dead-code считается отдельно (не входит в +// DetectAllViolationsWithConfig). Это ровно тот набор, по которому строится baseline +// и оценивается регрессия в scan. +func errorClassViolations(graph *model.Graph, a *analyzer.GoAnalyzer, cfg *archlintcfg.Config) []mcp.Violation { + viols := mcp.DetectAllViolationsWithConfig(graph, cfg) + if a != nil { + viols = append(viols, mcp.DeadCode(graph, cfg.EntryPoints)...) + } + return viols +} diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index 75547d17..3a254316 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -37,10 +37,10 @@ func captureStdout(t *testing.T, f func()) string { old := os.Stdout os.Stdout = w f() - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) return buf.String() } diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 9cc42519..c99f8161 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -39,11 +39,11 @@ func TestCollectWorkflow(t *testing.T) { // Use the cli package itself as source - always a valid Go directory. runErr := runCollect(nil, []string{"."}) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) if runErr != nil { t.Fatalf("runCollect failed: %v", runErr) @@ -148,11 +148,11 @@ metadata: runErr := runValidate(nil, nil) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) output := buf.String() if runErr != nil { @@ -204,11 +204,11 @@ metadata: runErr := runValidate(nil, nil) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) if runErr != nil { t.Fatalf("runValidate json failed: %v", runErr) @@ -246,11 +246,11 @@ func TestCheckWorkflow(t *testing.T) { runErr := runCheck(nil, []string{sampleDir}) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) if runErr != nil { t.Fatalf("runCheck failed: %v", runErr) @@ -285,11 +285,11 @@ func TestMetricsWorkflow(t *testing.T) { runErr := runMetrics(nil, []string{"."}) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) if runErr != nil { t.Fatalf("runMetrics failed: %v", runErr) diff --git a/internal/cli/monitor_test.go b/internal/cli/monitor_test.go index c71aed14..bc0bbe85 100644 --- a/internal/cli/monitor_test.go +++ b/internal/cli/monitor_test.go @@ -164,11 +164,11 @@ func TestRunMonitorList_Empty(t *testing.T) { err := runMonitorList(nil, nil) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -201,11 +201,11 @@ func TestRunMonitorList_WithRepos(t *testing.T) { err := runMonitorList(nil, nil) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) output := buf.String() if err != nil { @@ -241,10 +241,10 @@ func TestRunMonitorAdd_NewRepo(t *testing.T) { err := runMonitorAdd(nil, []string{"https://github.com/example/myrepo"}) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -321,10 +321,10 @@ func TestRunMonitorRemove_Existing(t *testing.T) { err := runMonitorRemove(nil, []string{"https://github.com/example/myrepo"}) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/internal/cli/scan.go b/internal/cli/scan.go index fb8a48eb..0b496255 100644 --- a/internal/cli/scan.go +++ b/internal/cli/scan.go @@ -17,11 +17,12 @@ import ( ) var ( - scanFormat string - scanThreshold int - scanConfigFile string - scanStdin bool - scanExclude []string + scanFormat string + scanThreshold int + scanConfigFile string + scanStdin bool + scanExclude []string + scanBaselineFile string ) var scanCmd = &cobra.Command{ @@ -57,6 +58,7 @@ func init() { scanCmd.Flags().StringVar(&scanConfigFile, "config", "", "Path to .archlint.yaml config file (default: /.archlint.yaml)") scanCmd.Flags().BoolVar(&scanStdin, "stdin", false, "Read architecture YAML graph from stdin instead of analyzing a directory") scanCmd.Flags().StringSliceVar(&scanExclude, "exclude", nil, "Directory basenames to skip during the source walk (additive on top of built-in defaults). Repeatable.") + scanCmd.Flags().StringVar(&scanBaselineFile, "baseline", "", "Path to .archlint-baseline.json for delta gating (default: /.archlint-baseline.json). Absent baseline -> audit mode (no block on ERROR patterns).") rootCmd.AddCommand(scanCmd) } @@ -65,9 +67,11 @@ type scanGateResult struct { Passed bool `json:"passed"` Violations int `json:"violations"` Threshold int `json:"threshold"` + Blocking int `json:"blocking"` // НОВЫЕ ERROR-class паттерны vs baseline (регрессии) Categories map[string]int `json:"categories"` Details []mcp.Violation `json:"details"` ConfigFile string `json:"config_file,omitempty"` + Baseline string `json:"baseline,omitempty"` // путь к загруженному snapshot ("" = audit-режим) } func runScan(cmd *cobra.Command, args []string) error { @@ -77,6 +81,7 @@ func runScan(cmd *cobra.Command, args []string) error { var graph *model.Graph var a *analyzer.GoAnalyzer + var baselineDir string // каталог для дефолтного пути baseline (пусто в stdin-режиме) if scanStdin { // Read YAML graph from stdin. @@ -103,6 +108,7 @@ func runScan(cmd *cobra.Command, args []string) error { os.Exit(2) } codeDir := args[0] + baselineDir = codeDir if _, err := os.Stat(codeDir); os.IsNotExist(err) { fmt.Fprintf(os.Stderr, "error: %v: %s\n", errDirNotExist, codeDir) @@ -156,6 +162,12 @@ func runScan(cmd *cobra.Command, args []string) error { // Structural violations (coupling, cycles) — config-aware. violations := mcp.DetectAllViolationsWithConfig(graph, &cfg) + // Dead-code (ERROR-class, open-world) — Go-граф only. Участвует в дельта-гейте: + // НОВЫЙ мёртвый узел vs baseline = регрессия (блок + удаление human-in-loop). + if a != nil { + violations = append(violations, mcp.DeadCode(graph, cfg.EntryPoints)...) + } + // Per-file SOLID and smell violations (Go projects only). var allMetrics map[string]*mcp.FileMetrics if a != nil { @@ -236,14 +248,59 @@ func runScan(cmd *cobra.Command, args []string) error { return violations[i].Target < violations[j].Target }) - // Determine threshold: -1 means any violation fails (equivalent to threshold 0). + // --- Delta gate (Фаза 5, DR-0034) --- + // Загружаем baseline-снимок: отсутствует -> nil -> ERROR-class паттерны + // деградируют в audit (NO-BASELINE -> NO-BLOCK). Дельта-гейт блокирует ТОЛЬКО + // НОВЫЕ vs baseline ERROR-class паттерны (SCC/layer/dead-code); магнитуды + // (WARNING/INFO) дельта-гейтом не блокируются (Ось-1). + baselinePath := scanBaselineFile + if baselinePath == "" && baselineDir != "" { + baselinePath = filepath.Join(baselineDir, defaultBaselineName) + } + var baseline *mcp.Baseline + if baselinePath != "" { + b, err := loadBaseline(baselinePath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(2) + } + baseline = b + } + + // Threshold count gate applies ТОЛЬКО к не-ERROR-class нарушениям (магнитуды/ + // WARNING): ERROR-class управляются дельта-гейтом, не абсолютным счётом. threshold := scanThreshold if threshold < 0 { threshold = 0 } + isErrorClass := func(kind string) bool { + c, ok := mcp.ClassOf(kind) + return ok && c.Class == "ERROR" + } + + blocking := 0 // НОВЫЕ ERROR-class паттерны (регрессия) -> блок + nonErrorCount := 0 // не-ERROR нарушения -> подлежат threshold-гейту + for _, v := range violations { + if mcp.EffectiveLevel(v, &cfg, baseline) == archlintcfg.LevelTaboo { + blocking++ + } + if !isErrorClass(v.Kind) { + nonErrorCount++ + } + } + + countPassed := nonErrorCount <= threshold + passed := countPassed && blocking == 0 + total := len(violations) - passed := total <= threshold + + // Путь baseline для отчёта: показываем только при реально загруженном снимке + // (nil = audit-режим, no-baseline -> no-block). + loadedBaseline := "" + if baseline != nil { + loadedBaseline = baselinePath + } // Build categories map. categories := make(map[string]int) @@ -257,9 +314,11 @@ func runScan(cmd *cobra.Command, args []string) error { Passed: passed, Violations: total, Threshold: threshold, + Blocking: blocking, Categories: categories, Details: violations, ConfigFile: configFile, + Baseline: loadedBaseline, } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") @@ -271,6 +330,11 @@ func runScan(cmd *cobra.Command, args []string) error { if configFile != "" { fmt.Printf("config: %s\n", configFile) } + if loadedBaseline != "" { + fmt.Printf("baseline: %s (delta gate)\n", loadedBaseline) + } else { + fmt.Printf("baseline: none (audit mode — ERROR patterns reported, not blocked)\n") + } if total == 0 { fmt.Printf("PASSED: No violations found (threshold: %d)\n", threshold) } else { @@ -278,10 +342,12 @@ func runScan(cmd *cobra.Command, args []string) error { if !passed { status = "FAILED" } - fmt.Printf("%s: %d violations found (threshold: %d)\n\n", status, total, threshold) + fmt.Printf("%s: %d violations found (threshold: %d, blocking regressions: %d)\n\n", status, total, threshold, blocking) for _, v := range violations { - level := mcp.ViolationLevel(v, &cfg) + // Дельта-уровень: НОВЫЙ ERROR-паттерн -> [ERROR]; существующий/без + // baseline -> аудит; магнитуды -> их обычный уровень. + level := mcp.EffectiveLevel(v, &cfg, baseline) prefix := mcp.LevelPrefix(level) fmt.Printf("%s [%s] %s\n", prefix, v.Kind, v.Message) if v.Target != "" { diff --git a/internal/cli/scan_test.go b/internal/cli/scan_test.go index 7583423c..efaf243e 100644 --- a/internal/cli/scan_test.go +++ b/internal/cli/scan_test.go @@ -28,11 +28,11 @@ func TestScanTextNoViolations(t *testing.T) { // Use the internal directory so we always have a valid Go directory. runErr := runScan(nil, []string{"."}) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) output := buf.String() // With threshold=9999 any codebase should pass. @@ -62,11 +62,11 @@ func TestScanJSONFormat(t *testing.T) { runErr := runScan(nil, []string{"."}) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) output := buf.String() if runErr != nil { @@ -115,11 +115,11 @@ func TestScanGateThresholdZero(t *testing.T) { runErr := runScan(nil, []string{"."}) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) output := buf.String() if runErr != nil { diff --git a/internal/cli/selfscan.go b/internal/cli/selfscan.go index 1cc41168..02dea1c7 100644 --- a/internal/cli/selfscan.go +++ b/internal/cli/selfscan.go @@ -119,15 +119,9 @@ func runSelfScan(_ *cobra.Command, _ []string) error { totalHealth += m.HealthScore // Collect SOLID + smell violations for display. - for _, v := range m.SRPViolations { - violations = append(violations, v) - } - for _, v := range m.DIPViolations { - violations = append(violations, v) - } - for _, v := range m.ISPViolations { - violations = append(violations, v) - } + violations = append(violations, m.SRPViolations...) + violations = append(violations, m.DIPViolations...) + violations = append(violations, m.ISPViolations...) } // Average health score per package. diff --git a/internal/cli/selfscan_test.go b/internal/cli/selfscan_test.go index 9a9c4ab9..7d3c7efc 100644 --- a/internal/cli/selfscan_test.go +++ b/internal/cli/selfscan_test.go @@ -18,11 +18,11 @@ func TestSelfScanText(t *testing.T) { err = runSelfScan(nil, nil) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) output := buf.String() if err != nil { @@ -57,11 +57,11 @@ func TestSelfScanMarkdown(t *testing.T) { err = runSelfScan(nil, nil) - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) output := buf.String() if err != nil { diff --git a/internal/cli/serve.go b/internal/cli/serve.go index 6bf8ee71..13e6c275 100644 --- a/internal/cli/serve.go +++ b/internal/cli/serve.go @@ -45,7 +45,7 @@ func runServe(_ *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("error creating MCP server: %w", err) } - defer server.Close() + defer func() { _ = server.Close() }() if err := server.Run(); err != nil { return fmt.Errorf("MCP server error: %w", err) diff --git a/internal/cli/validate.go b/internal/cli/validate.go index c26fbe94..feb2f9a8 100644 --- a/internal/cli/validate.go +++ b/internal/cli/validate.go @@ -283,7 +283,7 @@ func runPythonValidator(archFile string) error { // printPythonResults formats and prints Python validator output. func printPythonResults(data []byte, format string) { if format == "json" || format == "yaml" { - os.Stdout.Write(data) + _, _ = os.Stdout.Write(data) return } @@ -291,7 +291,7 @@ func printPythonResults(data []byte, format string) { var results map[string]interface{} if err := yaml.Unmarshal(data, &results); err != nil { // Fall back to raw output - os.Stdout.Write(data) + _, _ = os.Stdout.Write(data) return } diff --git a/internal/cli/watch.go b/internal/cli/watch.go index 9022c5aa..d3d431d5 100644 --- a/internal/cli/watch.go +++ b/internal/cli/watch.go @@ -55,7 +55,7 @@ func runWatch(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("failed to create watcher: %w", err) } - defer watcher.Close() + defer func() { _ = watcher.Close() }() // Add directory recursively. if err := addDirRecursive(watcher, absDir); err != nil { diff --git a/internal/mcp/baseline.go b/internal/mcp/baseline.go new file mode 100644 index 00000000..c5d698a3 --- /dev/null +++ b/internal/mcp/baseline.go @@ -0,0 +1,95 @@ +package mcp + +import "sort" + +// Baseline — снимок ERROR-class паттерн-фактов кода (Фаза 5, дельта-инфраструктура, +// DR-0034). Дельта-гейт сравнивает ТЕКУЩИЕ паттерны с этим снимком: появившийся +// (NEW) ERROR-паттерн = регрессия -> блок; уже бывший в baseline -> аудит (telemetry). +// +// Назначение — соундность-СОХРАНЯЮЩАЯ активация уже-соундных детекторов (SCC, +// dead-code, layer-backedge) в боевом гейте: на легаси с N давними нарушениями +// абсолютный режим бесполезен (всё красное), дельта блокирует только НОВОЕ (DR-0007). +// +// ★ДЕТЕРМИНИЗМ (DR-0034): два снимка одного кода обязаны быть БАЙТ-идентичны. +// Поэтому Patterns сериализуется как map[Kind][]fingerprint, где каждый список +// ОТСОРТИРОВАН и дедуплицирован, а encoding/json маршалит ключи map в +// лексикографическом порядке. Никакой зависимости от порядка обхода map. +type Baseline struct { + Version int `json:"version"` + // Patterns: Kind нарушения -> отсортированный уникальный набор fingerprint'ов. + Patterns map[string][]string `json:"patterns"` +} + +// Fingerprint — СТРОГАЯ идентичность экземпляра паттерна для дельты (DR-0034 п.3). +// НЕ fuzzy / НЕ rename-tracking (Ось-1 запрещает магнитудный матч на гейте); +// переименование -> ложный-NEW -> irritation (приемлемо, fail-safe в безопасную +// сторону, НЕ чиним). Ключ строится из ДЕТЕРМИНИРОВАННЫХ полей Violation: +// - circular-dependency: отсортированное множество member-qname SCC (несётся +// в Message детерминированно: detectCycles сортирует членов). Это коллапсирует +// P per-package дубликатов одного цикла в ОДНУ идентичность (идентичность SCC = +// множество членов, а не отдельный пакет). +// - layer-violation: пара (From -> To) — несётся в Message детерминированно. +// - прочие (dead-code и др.): строгий qname-key = Target. +func Fingerprint(v Violation) string { + switch v.Kind { + case "circular-dependency", "layer-violation": + // Message построен из отсортированных/стабильных полей -> детерминирован + // и кодирует строгую структурную идентичность (член-множество / пару). + return v.Message + default: + return v.Target + } +} + +// errorClass сообщает, относится ли Kind к ERROR-классу (реестр severity_class). +// Только ERROR-class паттерны участвуют в дельта-гейте; WARNING/INFO (магнитуды, +// DIP/SRP/coupling) НИКОГДА не блокируют (Ось-1, DR-0009). +func errorClass(kind string) bool { + c, ok := ClassOf(kind) + return ok && c.Class == "ERROR" +} + +// BuildBaseline собирает снимок из ERROR-class нарушений. Не-ERROR игнорируются +// (дельта-гейт оперирует только блокирующими паттернами). Результат детерминирован: +// каждый список отсортирован и дедуплицирован. +func BuildBaseline(violations []Violation) *Baseline { + b := &Baseline{Version: 1, Patterns: make(map[string][]string)} + seen := make(map[string]map[string]bool) + + for _, v := range violations { + if !errorClass(v.Kind) { + continue + } + fp := Fingerprint(v) + if seen[v.Kind] == nil { + seen[v.Kind] = make(map[string]bool) + } + if seen[v.Kind][fp] { + continue + } + seen[v.Kind][fp] = true + b.Patterns[v.Kind] = append(b.Patterns[v.Kind], fp) + } + + for k := range b.Patterns { + sort.Strings(b.Patterns[k]) + } + + return b +} + +// Contains сообщает, присутствовал ли паттерн v в baseline (по строгому fingerprint). +// nil-baseline -> false (никакой паттерн не "существующий" -> на гейте обрабатывается +// как audit-fallback в EffectiveLevel, НЕ как блок). +func (b *Baseline) Contains(v Violation) bool { + if b == nil { + return false + } + fp := Fingerprint(v) + for _, x := range b.Patterns[v.Kind] { + if x == fp { + return true + } + } + return false +} diff --git a/internal/mcp/baseline_test.go b/internal/mcp/baseline_test.go new file mode 100644 index 00000000..39d814cb --- /dev/null +++ b/internal/mcp/baseline_test.go @@ -0,0 +1,160 @@ +package mcp + +import ( + "encoding/json" + "testing" + + "github.com/mshogin/archlint/internal/archlintcfg" +) + +// helpers --------------------------------------------------------------------- + +func cycleViol(target string, members string) Violation { + // Message детерминирован (detectCycles сортирует членов) -> идентичность SCC. + return Violation{Kind: "circular-dependency", Target: target, Message: "Circular dependency detected (SCC size 2): " + members} +} + +func deadViol(qname string) Violation { + return Violation{Kind: "dead-code", Target: qname, Message: "dead code: " + qname + " недостижим от entry points R"} +} + +func layerViol(from, to string) Violation { + return Violation{Kind: "layer-violation", Target: from, Message: "Forbidden dependency: " + from + " (app) -> " + to + " (infra)"} +} + +// ЯДРО ГОРНИЛА (DR-0034): baseline на коде -> повторная дельта ТОГО ЖЕ кода ПУСТА. +func TestDeltaGate_IdempotentEmptyDelta(t *testing.T) { + current := []Violation{ + cycleViol("a", "a <-> b"), + cycleViol("b", "a <-> b"), // тот же цикл, другой пакет -> та же идентичность + deadViol("internal/x.Foo"), + layerViol("internal/app", "internal/infra"), + } + base := BuildBaseline(current) + + d := Delta(current, base) + if len(d.New) != 0 { + t.Fatalf("ожидалась ПУСТАЯ дельта на неизменном коде, получено NEW=%d: %+v", len(d.New), d.New) + } + if len(d.Existing) != len(current) { + t.Errorf("все текущие должны быть Existing: got %d/%d", len(d.Existing), len(current)) + } +} + +// +1 ДЕТЕРМИНИЗМ: два baseline одного кода БАЙТ-идентичны. +func TestDeltaGate_DeterministicSnapshot(t *testing.T) { + // Порядок входа НАМЕРЕННО разный -> снимок обязан совпасть байт-в-байт. + v1 := []Violation{ + layerViol("internal/app", "internal/infra"), + deadViol("internal/z.Bar"), + cycleViol("b", "a <-> b"), + deadViol("internal/x.Foo"), + cycleViol("a", "a <-> b"), + } + v2 := []Violation{ + deadViol("internal/x.Foo"), + cycleViol("a", "a <-> b"), + deadViol("internal/z.Bar"), + cycleViol("b", "a <-> b"), + layerViol("internal/app", "internal/infra"), + } + + b1, err := json.MarshalIndent(BuildBaseline(v1), "", " ") + if err != nil { + t.Fatal(err) + } + b2, err := json.MarshalIndent(BuildBaseline(v2), "", " ") + if err != nil { + t.Fatal(err) + } + if string(b1) != string(b2) { + t.Fatalf("снимки НЕ байт-идентичны:\n--- 1 ---\n%s\n--- 2 ---\n%s", b1, b2) + } +} + +// +2 NO-BASELINE -> NO-BLOCK: nil baseline -> ERROR-class деградирует в telemetry. +func TestDeltaGate_NoBaselineNoBlock(t *testing.T) { + for _, v := range []Violation{cycleViol("a", "a <-> b"), deadViol("p.Foo"), layerViol("a", "b")} { + if lvl := EffectiveLevel(v, nil, nil); lvl != archlintcfg.LevelTelemetry { + t.Errorf("%s без baseline: ожидался Telemetry (no-block), получен %v", v.Kind, lvl) + } + } +} + +// +3 POSITIVE CONTROL: 1 новый дефект -> дельта = ровно он; откат -> пуста. +func TestDeltaGate_PositiveControl(t *testing.T) { + baselineViols := []Violation{cycleViol("a", "a <-> b")} + base := BuildBaseline(baselineViols) + + withNewDead := append([]Violation{}, baselineViols...) + withNewDead = append(withNewDead, deadViol("internal/x.Leaked")) + + d := Delta(withNewDead, base) + if len(d.New) != 1 || d.New[0].Target != "internal/x.Leaked" { + t.Fatalf("ожидался ровно 1 NEW (internal/x.Leaked), получено: %+v", d.New) + } + + // откат (вернули исходный код) -> дельта пуста. + if d := Delta(baselineViols, base); len(d.New) != 0 { + t.Errorf("после отката дельта должна быть пуста, получено NEW=%d", len(d.New)) + } +} + +// +4 RENAME-CASE: переименование -> ожидаемо ложный-NEW (документируем, НЕ баг). +func TestDeltaGate_RenameIsFalseNew(t *testing.T) { + base := BuildBaseline([]Violation{deadViol("internal/x.OldName")}) + // тот же мёртвый код, переименован -> строгий qname-key изменился -> NEW. + d := Delta([]Violation{deadViol("internal/x.NewName")}, base) + if len(d.New) != 1 { + t.Fatalf("rename должен дать ложный-NEW (fail-safe, irritation), получено NEW=%d", len(d.New)) + } +} + +// +5 ПО КЛАССАМ + 4 исходных голдена ТЗ через EffectiveLevel. +func TestDeltaGate_GateLevels(t *testing.T) { + base := BuildBaseline([]Violation{ + cycleViol("a", "a <-> b"), + deadViol("internal/x.Existing"), + }) + + cases := []struct { + name string + v Violation + base *Baseline + want archlintcfg.Level + }{ + // Голден 1: новый цикл vs baseline -> ERROR-block. + {"new-cycle-blocks", cycleViol("c", "c <-> d"), base, archlintcfg.LevelTaboo}, + // Голден 2: существующий цикл (в baseline) -> telemetry no-block. + {"existing-cycle-audit", cycleViol("a", "a <-> b"), base, archlintcfg.LevelTelemetry}, + // Голден 3: новый dead -> block. + {"new-dead-blocks", deadViol("internal/x.New"), base, archlintcfg.LevelTaboo}, + // существующий dead -> telemetry. + {"existing-dead-audit", deadViol("internal/x.Existing"), base, archlintcfg.LevelTelemetry}, + // Голден 4: no baseline -> audit fallback. + {"no-baseline-audit", cycleViol("c", "c <-> d"), nil, archlintcfg.LevelTelemetry}, + // новый layer back-edge -> block. + {"new-layer-blocks", layerViol("internal/app", "internal/infra"), base, archlintcfg.LevelTaboo}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := EffectiveLevel(tc.v, nil, tc.base); got != tc.want { + t.Errorf("%s: got %v, want %v", tc.name, got, tc.want) + } + }) + } +} + +// не-ERROR-class НИКОГДА не блокирует через дельта-гейт (Ось-1: магнитуды не гейт). +func TestDeltaGate_NonErrorClassNeverBlocks(t *testing.T) { + base := BuildBaseline(nil) + warn := Violation{Kind: "high-efferent-coupling", Target: "p", Message: "coupling"} + if lvl := EffectiveLevel(warn, &archlintcfg.Config{}, base); lvl == archlintcfg.LevelTaboo { + t.Errorf("WARNING-магнитуда не должна блокировать дельта-гейтом, получен %v", lvl) + } + // и не должна попадать в baseline. + if len(BuildBaseline([]Violation{warn}).Patterns) != 0 { + t.Errorf("не-ERROR-class не должен попадать в baseline-снимок") + } +} diff --git a/internal/mcp/cycles_scc.go b/internal/mcp/cycles_scc.go new file mode 100644 index 00000000..54717da2 --- /dev/null +++ b/internal/mcp/cycles_scc.go @@ -0,0 +1,128 @@ +package mcp + +import ( + "sync" + + "github.com/mshogin/archlint/internal/model" + "gonum.org/v1/gonum/graph/simple" + "gonum.org/v1/gonum/graph/topo" +) + +// DirectedView — graph-agnostic вид направленного графа для метрик (по образцу +// gonum graph.Directed): множество узлов + соседи. Метрика принимает ИНТЕРФЕЙС, +// не конкретный model.Graph -> одна метрика гоняется на исходном графе, quotient- +// графе и т.д., не зная конкретного типа (ADR-0002 Этап 1). +type DirectedView interface { + NodeIDs() []string + Successors(id string) []string +} + +// importView — адаптер model.Graph по import-рёбрам к DirectedView. +type importView struct { + nodes []string + adj map[string][]string +} + +func newImportView(g *model.Graph) *importView { + adj := make(map[string][]string) + set := make(map[string]bool) + + for _, e := range g.Edges { + if e.Type == model.EdgeImport { + adj[e.From] = append(adj[e.From], e.To) + set[e.From] = true + set[e.To] = true + } + } + + nodes := make([]string, 0, len(set)) + for n := range set { + nodes = append(nodes, n) + } + + return &importView{nodes: nodes, adj: adj} +} + +func (v *importView) NodeIDs() []string { return v.nodes } +func (v *importView) Successors(id string) []string { return v.adj[id] } + +// sccResult — индекс SCC: cyclic-членство + члены SCC каждого узла. Считается +// ОДИН раз на граф (SCC не зависит от стартового узла — DR-0011). +type sccResult struct { + cyclic map[string]bool + members map[string][]string // node -> члены его SCC (для SCC>1); пусто для self-loop +} + +// computeSCC строит SCC-индекс по graph-agnostic виду через Tarjan (gonum). +// Узел в цикле ⟺ SCC>1 (взаимно достижимы) ИЛИ петля (self-loop). Соундно+полно. +func computeSCC(view DirectedView) *sccResult { + g := simple.NewDirectedGraph() + ids := make(map[string]int64) + names := make(map[int64]string) + self := make(map[string]bool) + + var next int64 + idOf := func(s string) int64 { + if v, ok := ids[s]; ok { + return v + } + v := next + next++ + ids[s] = v + names[v] = s + g.AddNode(simple.Node(v)) + return v + } + + for _, n := range view.NodeIDs() { + idOf(n) + } + for _, from := range view.NodeIDs() { + for _, to := range view.Successors(from) { + if from == to { + self[from] = true + continue + } + g.SetEdge(simple.Edge{F: simple.Node(idOf(from)), T: simple.Node(idOf(to))}) + } + } + + res := &sccResult{cyclic: make(map[string]bool), members: make(map[string][]string)} + + for _, comp := range topo.TarjanSCC(g) { + if len(comp) <= 1 { + continue + } + + mem := make([]string, 0, len(comp)) + for _, n := range comp { + mem = append(mem, names[n.ID()]) + } + for _, n := range comp { + res.cyclic[names[n.ID()]] = true + res.members[names[n.ID()]] = mem + } + } + + for n := range self { + res.cyclic[n] = true // петля = цикл (members пусто -> сам узел) + } + + return res +} + +// sccMemo мемоизирует SCC-индекс по указателю графа: detectCycles зовётся по +// каждому пакету (P раз), но SCC одинаков -> считаем ОДИН раз на граф (DR-0011). +// Допущение: граф в рамках анализа неизменен (строится один раз, читается). +var sccMemo sync.Map // *model.Graph -> *sccResult + +func cyclicSCC(g *model.Graph) *sccResult { + if r, ok := sccMemo.Load(g); ok { + return r.(*sccResult) + } + + r := computeSCC(newImportView(g)) + sccMemo.Store(g, r) + + return r +} diff --git a/internal/mcp/cycles_scc_test.go b/internal/mcp/cycles_scc_test.go new file mode 100644 index 00000000..4741c051 --- /dev/null +++ b/internal/mcp/cycles_scc_test.go @@ -0,0 +1,64 @@ +package mcp + +import ( + "testing" + + "github.com/mshogin/archlint/internal/model" +) + +// fakeView — произвольный DirectedView НЕ из model.Graph: доказывает graph-agnostic +// потребление (метрика гоняется на любом виде, не зная конкретного типа). +type fakeView struct { + nodes []string + adj map[string][]string +} + +func (f fakeView) NodeIDs() []string { return f.nodes } +func (f fakeView) Successors(id string) []string { return f.adj[id] } + +// (1) computeSCC работает на ПРОИЗВОЛЬНОМ DirectedView (не model.Graph). +func TestComputeSCC_GraphAgnostic(t *testing.T) { + v := fakeView{ + nodes: []string{"a", "b", "c"}, + adj: map[string][]string{"a": {"b"}, "b": {"a"}}, // цикл a<->b, c отдельно + } + r := computeSCC(v) + if !r.cyclic["a"] || !r.cyclic["b"] { + t.Fatalf("a,b должны быть в цикле; %v", r.cyclic) + } + if r.cyclic["c"] { + t.Fatal("c не в цикле") + } + if len(r.members["a"]) != 2 { + t.Fatalf("SCC{a,b} размера 2; got %v", r.members["a"]) + } +} + +// (2) Мемоизация (DR-0011): тот же граф -> ТОТ ЖЕ индекс (один расчёт на граф, +// не P раз). Проверяем равенство указателей *sccResult. +func TestCyclicSCC_Memoized(t *testing.T) { + g := &model.Graph{Edges: []model.Edge{ + {From: "x", To: "y", Type: model.EdgeImport}, + {From: "y", To: "x", Type: model.EdgeImport}, + }} + r1 := cyclicSCC(g) + r2 := cyclicSCC(g) + if r1 != r2 { + t.Fatal("мемоизация: повторный вызов на том же графе должен вернуть тот же индекс") + } + if !r1.cyclic["x"] || !r1.cyclic["y"] { + t.Fatalf("x,y в цикле; %v", r1.cyclic) + } +} + +// (3) Self-loop через DirectedView -> cyclic, members пусто (узел сам). +func TestComputeSCC_SelfLoop(t *testing.T) { + v := fakeView{nodes: []string{"s"}, adj: map[string][]string{"s": {"s"}}} + r := computeSCC(v) + if !r.cyclic["s"] { + t.Fatal("self-loop узел должен быть cyclic") + } + if len(r.members["s"]) != 0 { + t.Fatalf("self-loop: members пусто (SCC размера 1); got %v", r.members["s"]) + } +} diff --git a/internal/mcp/deadcode.go b/internal/mcp/deadcode.go new file mode 100644 index 00000000..0c2f4a55 --- /dev/null +++ b/internal/mcp/deadcode.go @@ -0,0 +1,90 @@ +package mcp + +import ( + "fmt" + "sort" + + "github.com/mshogin/archlint/internal/model" +) + +// DeadCode — метрика мёртвого кода (Фаза 3): узлы func/method, недостижимые от +// множества entry points R по рёбрам calls ∪ references ∪ usesType ∪ returns ∪ +// contains, С РАСКРЫТИЕМ IMPLEMENTS-DISPATCH. +// +// ★IMPLEMENTS-DISPATCH (вход 2 горнила, destruction-критично): при достижении +// интерфейса I раскрываем на ВСЕ реализующие типы T (обратно по implements T->I) +// и далее их методы (via contains). Иначе реализация, вызываемая только через +// i.Foo() (без прямого calls-ребра, т.к. var-тип не резолвится), была бы +// ложно-мёртвой -> удаление живого. implements over-approx по имени -> dispatch +// тоже over-approx -> ложно-живой (дёшево), ложно-мёртвый невозможен. +// +// SEVERITY: метрика ТОЛЬКО считает; класс WARNING пока (DR-0020: ERROR после +// прохождения self-горнила соундности). +func DeadCode(g *model.Graph, configPatterns []string) []Violation { + r := EntryPoints(g, configPatterns) + + kind := make(map[string]string) + for _, n := range g.Nodes { + if n.Attrs != nil { + if k, ok := n.Attrs["kind"].(string); ok { + kind[n.ID] = k + } + } + } + + // Прямые рёбра достижимости + обратный индекс implements для dispatch. + fwd := make(map[string][]string) + implementers := make(map[string][]string) // interface -> [concrete types] + for _, e := range g.Edges { + switch e.Type { + case model.EdgeCalls, model.EdgeReferences, model.EdgeUses, model.EdgeReturns, model.EdgeContains: + fwd[e.From] = append(fwd[e.From], e.To) + case model.EdgeImplements: // T -> I; для dispatch нужен обратный обход + implementers[e.To] = append(implementers[e.To], e.From) + } + } + + reached := make(map[string]bool) + var queue []string + push := func(id string) { + if !reached[id] { + reached[id] = true + queue = append(queue, id) + } + } + + for id := range r { + push(id) + } + + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + + for _, to := range fwd[cur] { + push(to) + } + + // dispatch: достигнут интерфейс -> все его реализации (и их методы via contains). + if kind[cur] == model.KindInterface { + for _, t := range implementers[cur] { + push(t) + } + } + } + + var out []Violation + for _, n := range g.Nodes { + if (n.Entity == "function" || n.Entity == "method") && !reached[n.ID] { + out = append(out, Violation{ + Kind: "dead-code", + Message: fmt.Sprintf("dead code: %s недостижим от entry points R", n.ID), + Target: n.ID, + }) + } + } + + sort.Slice(out, func(i, j int) bool { return out[i].Target < out[j].Target }) + + return out +} diff --git a/internal/mcp/deadcode_test.go b/internal/mcp/deadcode_test.go new file mode 100644 index 00000000..465b2cc2 --- /dev/null +++ b/internal/mcp/deadcode_test.go @@ -0,0 +1,110 @@ +package mcp + +import ( + "testing" + + "github.com/mshogin/archlint/internal/model" +) + +func deadGraph(nodes []model.Node, edges []model.Edge) *model.Graph { + return &model.Graph{Nodes: nodes, Edges: edges} +} + +func isDead(vs []Violation, id string) bool { + for _, v := range vs { + if v.Kind == "dead-code" && v.Target == id { + return true + } + } + return false +} + +// (1) недостижимая func -> мёртвая; (2) достижимая через calls -> живая; +// (5) entry из R -> всегда живая. +func TestDeadCode_CallsReachAndOrphan(t *testing.T) { + g := deadGraph( + []model.Node{ + fn("p.main", "main"), // entry (default R) + fn("p.used", "used"), // unexported, вызывается main -> жива + fn("p.orphan", "orphan"), // unexported, никто не зовёт -> мёртвая + }, + []model.Edge{{From: "p.main", To: "p.used", Type: model.EdgeCalls}}, + ) + vs := DeadCode(g, nil) + if !isDead(vs, "p.orphan") { + t.Fatalf("orphan должна быть мёртвой; %v", vs) + } + if isDead(vs, "p.used") { + t.Fatal("used достижима через calls -> живая") + } + if isDead(vs, "p.main") { + t.Fatal("main = entry R -> всегда живая") + } +} + +// (3) ★callback через references -> живая (не мёртвая). +func TestDeadCode_ReferenceKeepsAlive(t *testing.T) { + g := deadGraph( + []model.Node{fn("p.main", "main"), fn("p.cb", "cb")}, // cb unexported, только referenced + []model.Edge{{From: "p.main", To: "p.cb", Type: model.EdgeReferences}}, + ) + if isDead(DeadCode(g, nil), "p.cb") { + t.Fatal("callback cb достижим через references -> НЕ мёртвый (иначе destruction)") + } +} + +// (4) ★реализация интерфейса, достижимая ТОЛЬКО через dispatch (i.Foo()) -> живая. +// main использует интерфейс I (usesType) -> I достигнут -> dispatch на T (T implements I) +// -> contains -> T.Foo жива, хотя прямого calls на T.Foo нет. +func TestDeadCode_ImplementsDispatch(t *testing.T) { + // Имена UNEXPORTED -> не попадают в дефолтный R. R = {main}. Достижимость + // интерфейса iface — ТОЛЬКО через usesType от живого main, реализация impl.do — + // ТОЛЬКО через dispatch. Так изолируем implements-dispatch. + g := deadGraph( + []model.Node{ + fn("p.main", "main"), + kindNode("p.iface", model.KindInterface), + kindNode("p.impl", model.KindConcrete), + meth("p.impl.do", "do"), // unexported, только через dispatch + meth("p.other.bar", "bar"), // unexported, не реализует iface, никто не зовёт -> мёртв + }, + []model.Edge{ + {From: "p.main", To: "p.iface", Type: model.EdgeUses}, // live func использует интерфейс + {From: "p.impl", To: "p.iface", Type: model.EdgeImplements}, + {From: "p.impl", To: "p.impl.do", Type: model.EdgeContains}, + }, + ) + vs := DeadCode(g, nil) + if isDead(vs, "p.impl.do") { + t.Fatalf("impl.do достижима через implements-dispatch (i.do()) -> живая; %v", vs) + } + if !isDead(vs, "p.other.bar") { + t.Fatalf("other.bar не достижим и не реализует используемый интерфейс -> мёртв; %v", vs) + } +} + +// (5) ★функция, вызванная ТОЛЬКО из теста (Test*) -> живая (test-reachability). +// Test* в авто-R -> helper достижим через calls от теста. +func TestDeadCode_TestOnlyReachable(t *testing.T) { + g := deadGraph( + []model.Node{ + fn("p.helper", "helper"), // prod, unexported, зовётся только тестом + fn("p.TestHelper", "TestHelper"), // тест-функция -> в R + }, + []model.Edge{{From: "p.TestHelper", To: "p.helper", Type: model.EdgeCalls}}, + ) + if isDead(DeadCode(g, nil), "p.helper") { + t.Fatal("helper вызван из Test* -> живой (test-reachability), не удаляем") + } +} + +// (бонус) exported func -> в R -> жива даже без входящих рёбер. +func TestDeadCode_ExportedIsEntry(t *testing.T) { + g := deadGraph( + []model.Node{fn("p.PublicAPI", "PublicAPI")}, + nil, + ) + if isDead(DeadCode(g, nil), "p.PublicAPI") { + t.Fatal("exported PublicAPI = entry R -> живая") + } +} diff --git a/internal/mcp/delta.go b/internal/mcp/delta.go new file mode 100644 index 00000000..dc2c7bdc --- /dev/null +++ b/internal/mcp/delta.go @@ -0,0 +1,23 @@ +package mcp + +// DeltaResult — разбиение текущих нарушений относительно baseline (Фаза 5). +type DeltaResult struct { + New []Violation // отсутствуют в baseline (регрессия для ERROR-class) + Existing []Violation // присутствуют в baseline (давние) +} + +// Delta классифицирует текущие нарушения относительно baseline (generic по Kind). +// Чистая классификация: NEW = не в baseline, Existing = в baseline. Решение о +// блокировке принимает гейт (EffectiveLevel), не Delta. nil-baseline -> всё в New +// (но гейт трактует nil как audit-fallback, см. EffectiveLevel). +func Delta(current []Violation, baseline *Baseline) DeltaResult { + var r DeltaResult + for _, v := range current { + if baseline.Contains(v) { + r.Existing = append(r.Existing, v) + } else { + r.New = append(r.New, v) + } + } + return r +} diff --git a/internal/mcp/dip.go b/internal/mcp/dip.go new file mode 100644 index 00000000..120cd4de --- /dev/null +++ b/internal/mcp/dip.go @@ -0,0 +1,107 @@ +package mcp + +import ( + "fmt" + "sort" + + "github.com/mshogin/archlint/internal/model" +) + +// OutEdge — типизированное исходящее ребро (To + Type) для DIP-вью. +type OutEdge struct { + To string + Type string +} + +// DIPView — факты, нужные DIP-метрике: узлы-интерфейсы, kind узла, типизированные +// исходящие рёбра. graph-agnostic (по духу ADR-0002): метрика гоняется на ЛЮБОМ +// виде графа (исходный / quotient), не зная конкретного типа. DirectedView (узлы+ +// соседи, для SCC) для DIP недостаточен — нужны kind + типы рёбер, поэтому свой вид. +type DIPView interface { + InterfaceNodes() []string + KindOf(id string) string + OutEdges(id string) []OutEdge +} + +// modelDIPView — адаптер model.Graph -> DIPView. +type modelDIPView struct { + kind map[string]string + out map[string][]OutEdge + ifaces []string +} + +func newDIPView(g *model.Graph) *modelDIPView { + v := &modelDIPView{kind: make(map[string]string), out: make(map[string][]OutEdge)} + + for _, n := range g.Nodes { + if n.Attrs == nil { + continue + } + if k, ok := n.Attrs["kind"].(string); ok { + v.kind[n.ID] = k + if k == model.KindInterface { + v.ifaces = append(v.ifaces, n.ID) + } + } + } + + for _, e := range g.Edges { + v.out[e.From] = append(v.out[e.From], OutEdge{To: e.To, Type: e.Type}) + } + + sort.Strings(v.ifaces) + + return v +} + +func (v *modelDIPView) InterfaceNodes() []string { return v.ifaces } +func (v *modelDIPView) KindOf(id string) string { return v.kind[id] } +func (v *modelDIPView) OutEdges(id string) []OutEdge { return v.out[id] } + +// detectDIP — нарушения принципа инверсии зависимостей: интерфейс (абстракция) +// ссылается на СВОЙ конкретный тип (деталь) в сигнатуре своего метода — ребро +// usesType/returns от интерфейса к узлу kind=concrete. +// +// Направление ошибки (соундность > полнота): цель не-concrete (интерфейс/внешний/ +// нерезолвимый — у внешних нет kind=concrete-узла, рёбра ведут только на резолвимые +// СВОИ типы) -> НЕ нарушение. Только свой concrete -> нарушение. Ложного firing на +// легальном (abstraction->abstraction, примитивы) нет. +// +// SEVERITY (DR-0007/DR-0006): метрика ТОЛЬКО считает нарушения; severity-класс = +// WARNING пока self-оракул DIP не размечен (ERROR требует прохождения размеченного +// оракула). Класс назначает гейт, не метрика. +func detectDIP(v DIPView) []Violation { + var out []Violation + + seen := make(map[[2]string]bool) + + for _, iface := range v.InterfaceNodes() { + for _, e := range v.OutEdges(iface) { + if e.Type != model.EdgeUses && e.Type != model.EdgeReturns { + continue + } + if e.To == iface || v.KindOf(e.To) != model.KindConcrete { + continue + } + + key := [2]string{iface, e.To} + if seen[key] { + continue + } + seen[key] = true + + out = append(out, Violation{ + Kind: "dip-abstraction-to-detail", + Message: fmt.Sprintf("DIP: интерфейс %s ссылается на конкретный тип %s в сигнатуре метода (абстракция зависит от детали)", iface, e.To), + Target: iface, + }) + } + } + + sort.Slice(out, func(i, j int) bool { return out[i].Message < out[j].Message }) + + return out +} + +// DetectDIP — порт DIP-метрики (Тир1, Фаза 3) на model.Graph через DIPView. +func DetectDIP(g *model.Graph) []Violation { return detectDIP(newDIPView(g)) } diff --git a/internal/mcp/dip_test.go b/internal/mcp/dip_test.go new file mode 100644 index 00000000..5b50500b --- /dev/null +++ b/internal/mcp/dip_test.go @@ -0,0 +1,85 @@ +package mcp + +import ( + "testing" + + "github.com/mshogin/archlint/internal/model" +) + +func kindNode(id, kind string) model.Node { + return model.Node{ID: id, Entity: "type", Attrs: map[string]any{"kind": kind}} +} + +func dipGraph(nodes []model.Node, edges []model.Edge) *model.Graph { + return &model.Graph{Nodes: nodes, Edges: edges} +} + +func dipHas(vs []Violation, iface, concrete string) bool { + for _, v := range vs { + if v.Kind == "dip-abstraction-to-detail" && v.Target == iface && + containsSub(v.Message, concrete) { + return true + } + } + return false +} +func containsSub(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// (1) Интерфейс возвращает/принимает СВОЙ concrete -> нарушение DIP. +func TestDIP_InterfaceToConcrete(t *testing.T) { + g := dipGraph( + []model.Node{kindNode("p.Store", model.KindInterface), kindNode("p.Record", model.KindConcrete)}, + []model.Edge{ + {From: "p.Store", To: "p.Record", Type: model.EdgeReturns}, + {From: "p.Store", To: "p.Record", Type: model.EdgeUses}, + }, + ) + vs := DetectDIP(g) + if !dipHas(vs, "p.Store", "p.Record") { + t.Fatalf("DIP: интерфейс->свой concrete не пойман; %v", vs) + } + if len(vs) != 1 { + t.Fatalf("дедуп: одно нарушение на пару (I,C), got %d: %v", len(vs), vs) + } +} + +// (2) Интерфейс ссылается на ДРУГУЮ абстракцию (interface) -> НЕ нарушение. +func TestDIP_InterfaceToInterface_NoViolation(t *testing.T) { + g := dipGraph( + []model.Node{kindNode("p.A", model.KindInterface), kindNode("p.B", model.KindInterface)}, + []model.Edge{{From: "p.A", To: "p.B", Type: model.EdgeUses}}, + ) + if vs := DetectDIP(g); len(vs) != 0 { + t.Fatalf("abstraction->abstraction НЕ нарушение DIP: %v", vs) + } +} + +// (3) Интерфейс ссылается на внешний/нерезолвимый узел (нет kind=concrete) -> НЕ нарушение. +func TestDIP_ExternalTarget_NoViolation(t *testing.T) { + g := dipGraph( + // p.Ext без Attrs.kind (внешний/нерезолвимый); p.I интерфейс + []model.Node{kindNode("p.I", model.KindInterface), {ID: "ext.Thing", Entity: "external"}}, + []model.Edge{{From: "p.I", To: "ext.Thing", Type: model.EdgeReturns}}, + ) + if vs := DetectDIP(g); len(vs) != 0 { + t.Fatalf("внешний/нерезолвимый таргет НЕ нарушение (соундность): %v", vs) + } +} + +// (4) calls-ребро (не usesType/returns) от интерфейса не считается DIP. +func TestDIP_OnlySignatureEdges(t *testing.T) { + g := dipGraph( + []model.Node{kindNode("p.I", model.KindInterface), kindNode("p.C", model.KindConcrete)}, + []model.Edge{{From: "p.I", To: "p.C", Type: "calls"}}, + ) + if vs := DetectDIP(g); len(vs) != 0 { + t.Fatalf("только usesType/returns -> DIP; calls не считается: %v", vs) + } +} diff --git a/internal/mcp/entrypoints.go b/internal/mcp/entrypoints.go new file mode 100644 index 00000000..e109381e --- /dev/null +++ b/internal/mcp/entrypoints.go @@ -0,0 +1,74 @@ +package mcp + +import ( + "strings" + "unicode" + + "github.com/mshogin/archlint/internal/model" +) + +// EntryPoints строит множество R — корни достижимости для dead-code (Фаза 3). +// R = АВТО-ДЕФОЛТ (детерминированный, НЕ эвристика) ∪ конфиг-паттерны. +// +// Дефолт ЩЕДРЫЙ по асимметрии цены (критерий 3 соундности): пропущенный entry -> +// функция без пути от R -> ложно-мёртвая -> удалили живое (destruction). Лишний +// entry -> ложно-живой (дёшево). Поэтому скупой R опаснее — берём щедро: +// - функция main / init (рантайм-входы бинарей и пакетов); +// - функция Test*/Benchmark*/Example* (тест ссылается на код -> код живой); +// - ЛЮБОЙ exported символ (func/method/type): публичный API = entry по +// определению (детерминированно по экспортируемости имени). +// +// configPatterns (.archlint entrypoints) ДОБАВЛЯЮТ узлы по подстроке ID — для +// того, что авто-дефолт не видит: framework-хендлеры, рефлексия/DI/codegen. +func EntryPoints(g *model.Graph, configPatterns []string) map[string]bool { + r := make(map[string]bool) + + for _, n := range g.Nodes { + if isDefaultEntry(n) { + r[n.ID] = true + } + } + + for _, n := range g.Nodes { + for _, p := range configPatterns { + if p != "" && strings.Contains(n.ID, p) { + r[n.ID] = true + + break + } + } + } + + return r +} + +// isDefaultEntry — авто-дефолтный entry: main/init/Test*/Benchmark*/Example* или +// любой exported func/method/type. Детерминированно по Entity + имени (Title). +func isDefaultEntry(n model.Node) bool { + switch n.Entity { + case "function": + switch { + case n.Title == "main" || n.Title == "init": + return true + case strings.HasPrefix(n.Title, "Test"), + strings.HasPrefix(n.Title, "Benchmark"), + strings.HasPrefix(n.Title, "Example"): + return true + default: + return isExported(n.Title) + } + case "method", "struct", "interface": + return isExported(n.Title) + } + + return false +} + +// isExported — Go-правило: имя экспортируемо, если начинается с заглавной. +func isExported(name string) bool { + if name == "" { + return false + } + + return unicode.IsUpper([]rune(name)[0]) +} diff --git a/internal/mcp/entrypoints_test.go b/internal/mcp/entrypoints_test.go new file mode 100644 index 00000000..a07ee6de --- /dev/null +++ b/internal/mcp/entrypoints_test.go @@ -0,0 +1,52 @@ +package mcp + +import ( + "testing" + + "github.com/mshogin/archlint/internal/model" +) + +func fn(id, title string) model.Node { return model.Node{ID: id, Title: title, Entity: "function"} } +func meth(id, title string) model.Node { return model.Node{ID: id, Title: title, Entity: "method"} } +func typ(id, title string) model.Node { return model.Node{ID: id, Title: title, Entity: "struct"} } + +// (1) main -> в R; (2) exported func -> в R; (3) unexported не-init/не-Test -> НЕ в дефолтном R. +func TestEntryPoints_Default(t *testing.T) { + g := &model.Graph{Nodes: []model.Node{ + fn("cmd/app.main", "main"), + fn("p.PublicAPI", "PublicAPI"), + fn("p.helper", "helper"), + fn("p.init", "init"), + fn("p.TestFoo", "TestFoo"), + meth("p.S.Exported", "Exported"), + meth("p.S.internal", "internal"), + typ("p.PublicType", "PublicType"), + typ("p.privateType", "privateType"), + }} + r := EntryPoints(g, nil) + + for _, want := range []string{"cmd/app.main", "p.PublicAPI", "p.init", "p.TestFoo", "p.S.Exported", "p.PublicType"} { + if !r[want] { + t.Errorf("%s должен быть в дефолтном R", want) + } + } + for _, notWant := range []string{"p.helper", "p.S.internal", "p.privateType"} { + if r[notWant] { + t.Errorf("%s НЕ должен быть в дефолтном R (unexported, не-entry)", notWant) + } + } +} + +// (4) конфиг-маркер (подстрока) ДОБАВЛЯЕТ узел в R сверх дефолта. +func TestEntryPoints_ConfigAdds(t *testing.T) { + g := &model.Graph{Nodes: []model.Node{ + fn("p.handleWebhook", "handleWebhook"), // unexported -> не в дефолте + }} + if EntryPoints(g, nil)["p.handleWebhook"] { + t.Fatal("без конфига handleWebhook не в R") + } + r := EntryPoints(g, []string{"handleWebhook"}) + if !r["p.handleWebhook"] { + t.Fatal("конфиг-паттерн handleWebhook должен добавить узел в R") + } +} diff --git a/internal/mcp/gatelevel.go b/internal/mcp/gatelevel.go new file mode 100644 index 00000000..d02907d4 --- /dev/null +++ b/internal/mcp/gatelevel.go @@ -0,0 +1,28 @@ +package mcp + +import "github.com/mshogin/archlint/internal/archlintcfg" + +// EffectiveLevel — ГЕЙТ-уровень нарушения с учётом дельта-режима (Фаза 5, DR-0034). +// Привязка к severity_class через errorClass/ClassOf: +// +// ERROR-class + baseline == nil -> Telemetry (NO-BASELINE -> NO-BLOCK, п.2: +// первый прогон НЕ absolute, не блокирует всё) +// ERROR-class + baseline + EXISTING -> Telemetry (давнее нарушение, не регрессия) +// ERROR-class + baseline + NEW -> Taboo (регрессия -> hard-block; для dead-code +// блок = сигнал, удаление human-in-loop отдельно) +// НЕ ERROR-class -> ViolationLevel(v,cfg) (WARNING/INFO как раньше) +// +// fail-safe (п.4): строгий fingerprint -> ошибки в безопасную сторону (над-блок = +// irritation, не под-блок = проскок регрессии). +func EffectiveLevel(v Violation, cfg *archlintcfg.Config, baseline *Baseline) archlintcfg.Level { + if errorClass(v.Kind) { + if baseline == nil { + return archlintcfg.LevelTelemetry // no-baseline -> no-block (audit fallback) + } + if baseline.Contains(v) { + return archlintcfg.LevelTelemetry // существующее -> аудит + } + return archlintcfg.LevelTaboo // NEW ERROR-паттерн -> блок + } + return ViolationLevel(v, cfg) +} diff --git a/internal/mcp/reach_srp.go b/internal/mcp/reach_srp.go index fa5b7e4e..4f0c5981 100644 --- a/internal/mcp/reach_srp.go +++ b/internal/mcp/reach_srp.go @@ -221,15 +221,6 @@ func computeRExt( return result } -// isInPackage returns true when nodeID starts with pkgID followed by ".". -// This is the convention used throughout the archlint graph builder. -func isInPackage(nodeID, pkgID string) bool { - if len(nodeID) <= len(pkgID) { - return false - } - return nodeID[:len(pkgID)+1] == pkgID+"." -} - // buildReachGraph constructs the equivalence adjacency list for the method set. // Three rules add edges: // 1. Shared resource: R(m_i) ∩ R(m_j) ≠ ∅. diff --git a/internal/mcp/reach_srp_test.go b/internal/mcp/reach_srp_test.go index 48596807..6bcf21b3 100644 --- a/internal/mcp/reach_srp_test.go +++ b/internal/mcp/reach_srp_test.go @@ -8,35 +8,6 @@ import ( "github.com/mshogin/archlint/internal/analyzer" ) -// writeAndAnalyze is a helper that writes Go source to a temp file, analyzes -// it, and returns the analyzer + graph. The returned typeID is for the named -// struct inside the written code. -func writeAndAnalyze(t *testing.T, code string) (*analyzer.GoAnalyzer, interface{ Nodes() int }, string) { - t.Helper() - return nil, nil, "" -} - -// analyzeCode is the real helper used by all reach-SRP tests. -func analyzeCode(t *testing.T, code string, typeName string) (string, *analyzer.GoAnalyzer, interface{}) { - t.Helper() - - tmpDir := t.TempDir() - goFile := filepath.Join(tmpDir, "code.go") - - if err := os.WriteFile(goFile, []byte(code), 0o644); err != nil { - t.Fatal(err) - } - - a := analyzer.NewGoAnalyzer() - graph, err := a.Analyze(tmpDir) - if err != nil { - t.Fatal(err) - } - - typeID := findTypeID(t, a, typeName) - return typeID, a, graph -} - // TestReachSRPDataClass: struct with 5 getters, no external calls → ρ=1 // (all methods are pure → unified into one class). func TestReachSRPDataClass(t *testing.T) { diff --git a/internal/mcp/severity_class.go b/internal/mcp/severity_class.go new file mode 100644 index 00000000..78ad4d63 --- /dev/null +++ b/internal/mcp/severity_class.go @@ -0,0 +1,49 @@ +package mcp + +// SeverityClass — ДЕКЛАРИРУЕМЫЙ класс важности метрики (DR-0029). ОТДЕЛЁН от +// ЭФФЕКТИВНОГО gate-level (ViolationLevel): для open-world-ERROR класс=ERROR +// заявлен сейчас (метрика прошла горнило), но боевая БЛОКИРОВКА требует +// дельта-режима + human-in-loop, чья инфраструктура — Фаза 5. До неё эффективный +// уровень держится в АУДИТ-режиме (отчёт, exit 0), не блок. +type SeverityClass struct { + // Class — заявленный класс: "ERROR" | "WARNING". + Class string + // OpenWorld — условно-соундная метрика: ERROR валиден ТОЛЬКО в дельта-режиме + // (новое нарушение vs baseline), не абсолютным числом. dead-code: реальность + // «мёртвости» зависит от полноты R (entry points), которая открыта. + OpenWorld bool + // RequiresDelta — боевая блокировка требует дельта-инфраструктуры (Фаза 5, + // общей для всех ERROR). До неё — аудит. + RequiresDelta bool + // HumanInLoop — авто-удаление/фикс только через подтверждение человека + // (destruction-cost: ошибка удаляет живой код). + HumanInLoop bool +} + +// violationClasses — реестр заявленных классов по Kind нарушения. +// +// Дельта-гейт (EffectiveLevel, DR-0034) активирует в боевом блоке ТОЛЬКО эти +// ERROR-class паттерны и только в дельта-режиме (NEW vs baseline). Градации +// соундности (Ось-1б): closed-world (SCC/слой — безусловно соундны, без замков) и +// open-world (dead-code — условно соунд, обязательны дельта+human-in-loop). +var violationClasses = map[string]SeverityClass{ + // circular-dependency — CLOSED-WORLD ERROR (SCC iff, DR-0005): цикл есть цикл, + // внешнего допущения нет. Дельта-режим здесь — usability (легаси), не условие + // соундности -> RequiresDelta=false. + "circular-dependency": {Class: "ERROR", OpenWorld: false, RequiresDelta: false, HumanInLoop: false}, + + // layer-violation — CLOSED-WORLD ERROR относительно объявленного L (back-edge + // против порядка слоёв, DR-0009 Уровень B). Соунд относительно конфига L. + "layer-violation": {Class: "ERROR", OpenWorld: false, RequiresDelta: false, HumanInLoop: false}, + + // dead-code промотирован в ERROR (полное горнило соундности: 0 false-dead на self). + // open-world: соунден только в дельта-режиме; блокировка — Фаза 5. Удаление — + // human-in-loop (destruction-cost: ложно-мёртвый удаляет живое). + "dead-code": {Class: "ERROR", OpenWorld: true, RequiresDelta: true, HumanInLoop: true}, +} + +// ClassOf возвращает заявленный класс важности для Kind нарушения (если объявлен). +func ClassOf(kind string) (SeverityClass, bool) { + c, ok := violationClasses[kind] + return c, ok +} diff --git a/internal/mcp/severity_class_test.go b/internal/mcp/severity_class_test.go new file mode 100644 index 00000000..b340c52a --- /dev/null +++ b/internal/mcp/severity_class_test.go @@ -0,0 +1,13 @@ +package mcp +import ("testing";"github.com/mshogin/archlint/internal/archlintcfg") +func TestSeverityClass_DeadCode(t *testing.T){ + c,ok:=ClassOf("dead-code") + if !ok || c.Class!="ERROR" || !c.OpenWorld || !c.RequiresDelta || !c.HumanInLoop { + t.Fatalf("dead-code класс должен быть ERROR/open-world/delta/human-in-loop; %+v ok=%v",c,ok) + } + // эффективный уровень = аудит (Telemetry, не блок) до Фазы 5 + cfg:=archlintcfg.Default() + if lvl:=ViolationLevel(Violation{Kind:"dead-code"},&cfg); lvl!=archlintcfg.LevelTelemetry { + t.Fatalf("dead-code эффективный уровень = аудит Telemetry до дельта-инфры; got %v",lvl) + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index b0e9793e..26ad2658 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "path/filepath" + "sort" "strings" "github.com/mshogin/archlint/internal/model" @@ -325,56 +326,33 @@ func DetectAllViolations(graph *model.Graph) []Violation { return violations } -// detectCycles searches for circular dependencies via import edges using DFS. +// detectCycles reports a circular-dependency Violation for startPkg if it +// participates in a cycle of the import graph. Делегирует graph-agnostic +// SCC-индексу (cycles_scc.go), который МЕМОИЗИРОВАН на граф (DR-0011): SCC не +// зависит от startPkg, поэтому считается один раз, а не P раз на каждый пакет. +// +// Принцип (DR-0005, чистый iff): узел в цикле ⟺ SCC размера>1 ИЛИ петля. +// Соундно+полно (циклы любой длины). Та же SCC-машина обслуживает карточку +// "слоистость Уровень A" (SCC>1 среди модулей = цикл модульных зависимостей). func detectCycles(graph *model.Graph, startPkg string) []Violation { - adj := make(map[string][]string) - - for _, edge := range graph.Edges { - if edge.Type == "import" { - adj[edge.From] = append(adj[edge.From], edge.To) - } + scc := cyclicSCC(graph) + if !scc.cyclic[startPkg] { + return nil } - visited := make(map[string]bool) - inStack := make(map[string]bool) - var cyclePath []string - - var dfs func(node string) bool - - dfs = func(node string) bool { - visited[node] = true - inStack[node] = true - cyclePath = append(cyclePath, node) - - for _, next := range adj[node] { - if next == startPkg && inStack[next] { - return true - } - - if !visited[next] { - if dfs(next) { - return true - } - } - } - - inStack[node] = false - cyclePath = cyclePath[:len(cyclePath)-1] - - return false + members := scc.members[startPkg] + if len(members) == 0 { + members = []string{startPkg} // self-loop: SCC размера 1 } - if dfs(startPkg) { - cycle := strings.Join(cyclePath, " -> ") + " -> " + startPkg - - return []Violation{{ - Kind: "circular-dependency", - Message: fmt.Sprintf("Circular dependency detected: %s", cycle), - Target: startPkg, - }} - } + sorted := append([]string(nil), members...) + sort.Strings(sorted) - return nil + return []Violation{{ + Kind: "circular-dependency", + Message: fmt.Sprintf("Circular dependency detected (SCC size %d): %s", len(sorted), strings.Join(sorted, " <-> ")), + Target: startPkg, + }} } // filterGraph filters the graph to only include nodes and edges related to filter. diff --git a/internal/mcp/tools_tarjan_test.go b/internal/mcp/tools_tarjan_test.go new file mode 100644 index 00000000..f3f8b061 --- /dev/null +++ b/internal/mcp/tools_tarjan_test.go @@ -0,0 +1,91 @@ +package mcp + +import ( + "fmt" + "strings" + "testing" + + "github.com/mshogin/archlint/internal/model" +) + +// importGraph строит model.Graph только из import-рёбер (как видит detectCycles). +func importGraph(edges [][2]string) *model.Graph { + g := &model.Graph{} + seen := map[string]bool{} + add := func(id string) { + if !seen[id] { + seen[id] = true + g.Nodes = append(g.Nodes, model.Node{ID: id, Entity: "package"}) + } + } + for _, e := range edges { + add(e[0]) + add(e[1]) + g.Edges = append(g.Edges, model.Edge{From: e[0], To: e[1], Type: "import"}) + } + return g +} + +// (1) Ацикличная цепочка — НЕ должно срабатывать (как archlint-на-себе, если ацикличен). +func TestDetectCycles_AcyclicChain(t *testing.T) { + g := importGraph([][2]string{{"n0", "n1"}, {"n1", "n2"}, {"n2", "n3"}, {"n3", "n4"}}) + for _, n := range []string{"n0", "n1", "n2", "n3", "n4"} { + if v := detectCycles(g, n); len(v) != 0 { + t.Fatalf("ацикличный граф: узел %s ложно-флагнут: %v", n, v) + } + } +} + +// (2) Цикл длины 12 — Tarjan SCC ловит. Старый simple_cycles(max_length=10) пропустил бы +// (демонстрация закрытия дыры полноты, DR-0008). +func TestDetectCycles_LongCycle12(t *testing.T) { + var edges [][2]string + for i := 0; i < 12; i++ { + edges = append(edges, [2]string{fmt.Sprintf("n%d", i), fmt.Sprintf("n%d", (i+1)%12)}) + } + g := importGraph(edges) + v := detectCycles(g, "n0") + if len(v) == 0 { + t.Fatal("ДЫРА: цикл длины 12 НЕ пойман (старый max_length=10 пропустил бы — это и чиним)") + } + if !strings.Contains(v[0].Message, "SCC size 12") { + t.Fatalf("ожидал SCC size 12 (вся петля), got: %s", v[0].Message) + } +} + +// (3) Ромбовый DAG A->B,A->C,B->D,C->D — 0 циклов, НЕ ложно-срабатывает. +func TestDetectCycles_DiamondNoCycle(t *testing.T) { + g := importGraph([][2]string{{"A", "B"}, {"A", "C"}, {"B", "D"}, {"C", "D"}}) + for _, n := range []string{"A", "B", "C", "D"} { + if v := detectCycles(g, n); len(v) != 0 { + t.Fatalf("ромб DAG: узел %s ложно-флагнут (β₁=1, но 0 циклов): %v", n, v) + } + } +} + +// (4) Петля X->X — цикл размера 1 через self-loop (SCC>1 его не ловит, отдельная ветка). +func TestDetectCycles_SelfLoop(t *testing.T) { + g := importGraph([][2]string{{"X", "X"}}) + if v := detectCycles(g, "X"); len(v) == 0 { + t.Fatal("self-loop X->X НЕ пойман") + } +} + +// (5) Два независимых цикла + ацикличный хвост — флагается только циклический. +func TestDetectCycles_MixedComponents(t *testing.T) { + g := importGraph([][2]string{ + {"a", "b"}, {"b", "a"}, // цикл {a,b} + {"c", "d"}, {"d", "c"}, // цикл {c,d} + {"e", "f"}, // ацикличное ребро + }) + for _, n := range []string{"a", "b", "c", "d"} { + if v := detectCycles(g, n); len(v) == 0 { + t.Fatalf("циклический узел %s НЕ пойман", n) + } + } + for _, n := range []string{"e", "f"} { + if v := detectCycles(g, n); len(v) != 0 { + t.Fatalf("ацикличный узел %s ложно-флагнут: %v", n, v) + } + } +} diff --git a/internal/mcp/violations_with_config.go b/internal/mcp/violations_with_config.go index d484de26..993211dd 100644 --- a/internal/mcp/violations_with_config.go +++ b/internal/mcp/violations_with_config.go @@ -172,6 +172,12 @@ func ViolationLevel(v Violation, cfg *archlintcfg.Config) archlintcfg.Level { return cfg.Rules.DIP.Level case v.Kind == "layer-violation": return archlintcfg.LevelTaboo // layer violations always block + case v.Kind == "dead-code": + // Заявленный класс = ERROR, open-world (см. violationClasses / ClassOf). + // НО эффективный уровень — АУДИТ (Telemetry, exit 0): боевая блокировка + // dead-code = ДЕЛЬТА-режим (новый мёртвый vs baseline) + human-in-loop, + // чья инфраструктура — Фаза 5. До неё — отчёт, не блок (DR-0029). + return archlintcfg.LevelTelemetry default: return archlintcfg.LevelTelemetry } diff --git a/internal/mcp/watcher.go b/internal/mcp/watcher.go index 4a15cf32..6685f476 100644 --- a/internal/mcp/watcher.go +++ b/internal/mcp/watcher.go @@ -63,7 +63,7 @@ func NewWatcher(rootDir string, handler FileChangeHandler, logger *log.Logger) ( return nil }) if err != nil { - fsw.Close() + _ = fsw.Close() return nil, err } @@ -81,7 +81,7 @@ func (w *Watcher) Start() { // Stop stops the file watcher and waits for the background goroutine to finish. func (w *Watcher) Stop() { close(w.stopCh) - w.watcher.Close() + _ = w.watcher.Close() w.wg.Wait() } diff --git a/internal/model/model.go b/internal/model/model.go index f42d25bd..37e02077 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -21,6 +21,19 @@ const ( EdgeEmbeds = "embeds" EdgeFieldRead = "field_read" EdgeFieldWrite = "field_write" + // EdgeImplements — concrete type -> interface (method-set сатисфакция с + // embeds-промоушеном). Материализуется в Фазе 1 (ADR-0002); нужен DIP/dead-code. + EdgeImplements = "implements" + // EdgeReturns — функция/метод -> тип в сигнатуре ВОЗВРАТА (type-flow, Фаза 1). + EdgeReturns = "returns" + // EdgeReferences — функция/метод используется как ЗНАЧЕНИЕ (callback), Фаза 1. + EdgeReferences = "references" +) + +// Type-kind значения для Node.Attrs["kind"] (ось абстрактности для DIP). +const ( + KindInterface = "interface" + KindConcrete = "concrete" ) // Graph представляет архитектурный граф. @@ -30,18 +43,23 @@ type Graph struct { } // Node представляет узел графа (компонент). +// Attrs/μ — property-graph мешок атрибутов (ADR-0002 Этап 1). Для type-узлов +// несёт "kind"=interface|concrete (нужен DIP). omitempty -> старые потребители не ломаются. type Node struct { - ID string `yaml:"id"` - Title string `yaml:"title"` - Entity string `yaml:"entity"` + ID string `yaml:"id"` + Title string `yaml:"title"` + Entity string `yaml:"entity"` + Attrs map[string]any `yaml:"attrs,omitempty"` } // Edge представляет ребро графа (связь между компонентами). +// Attrs/μ — property-graph мешок атрибутов ребра (ADR-0002 Этап 1). type Edge struct { - From string `yaml:"from"` - To string `yaml:"to"` - Method string `yaml:"method,omitempty"` - Type string `yaml:"type,omitempty"` + From string `yaml:"from"` + To string `yaml:"to"` + Method string `yaml:"method,omitempty"` + Type string `yaml:"type,omitempty"` + Attrs map[string]any `yaml:"attrs,omitempty"` } // TypeInfo содержит информацию о типе (struct/interface). @@ -54,6 +72,19 @@ type TypeInfo struct { Fields []FieldInfo Embeds []string Implements []string + // MethodSigs — методы, объявленные В ИНТЕРФЕЙСЕ (Kind=="interface"), с ПОЛНОЙ + // сигнатурой (имя + param/return type-refs). Имена -> method-set implements; + // param/return -> usesType/returns ОТ интерфейса (DIP: абстракция ссылается на + // конкрет в сигнатуре своего метода). Для struct пусто. Фундаментальный факт + // для DIP и будущего signature-точного implements. + MethodSigs []InterfaceMethodSig +} + +// InterfaceMethodSig — сигнатура одного метода интерфейса (без тела). +type InterfaceMethodSig struct { + Name string + Params []FieldInfo + Results []FieldInfo } // FieldInfo содержит информацию о поле структуры. @@ -70,6 +101,13 @@ type FunctionInfo struct { File string Line int Calls []CallInfo + // Params/Results — type-refs из СИГНАТУРЫ (Фаза 1): usesType покрывает param-типы, + // returns — типы возврата. FieldInfo.Name опционален (для type-ref не важен). + Params []FieldInfo + Results []FieldInfo + // Refs — функция/метод использован как ЗНАЧЕНИЕ (callback, Фаза 1). Резолв-фильтр + // в билдере оставит только реальные функции -> references-ребро. + Refs []CallInfo } // MethodInfo содержит информацию о методе. @@ -81,6 +119,13 @@ type MethodInfo struct { Line int Calls []CallInfo FieldAccess []FieldAccessInfo + // Params/Results — type-refs из СИГНАТУРЫ метода (Фаза 1): usesType/returns. + // Ключ DIP: у интерфейса нет тела -> без param-типов DIP молча пропустит + // param-нарушения (самый частый вектор). + Params []FieldInfo + Results []FieldInfo + // Refs — функция/метод как ЗНАЧЕНИЕ (callback, Фаза 1) -> references-ребро. + Refs []CallInfo } // FieldAccessInfo contains information about a field access within a method. diff --git a/tests/e2e_test.go b/tests/e2e_test.go index 85452835..7327f676 100644 --- a/tests/e2e_test.go +++ b/tests/e2e_test.go @@ -136,7 +136,10 @@ func TestE2EScanClean(t *testing.T) { } } -// TestE2EScanWithViolations verifies that scan fails when a layer violation is injected. +// TestE2EScanWithViolations verifies the DELTA gate (Фаза 5, DR-0034): a layer +// violation that is NEW relative to a clean baseline blocks the gate. Layer/cycle/ +// dead-code are ERROR-class and gated by delta — without a baseline they audit (no +// block); with a clean baseline a newly-introduced violation is a regression -> block. func TestE2EScanWithViolations(t *testing.T) { bin := e2eBinary(t) demo := demoDir(t) @@ -144,6 +147,10 @@ func TestE2EScanWithViolations(t *testing.T) { orderHandlerPath := filepath.Join(demo, "internal", "handler", "order.go") violationStep := filepath.Join(demo, "demo-scenario", "step1-quick-fix.go") + internalDir := filepath.Join(demo, "internal") + configFile := filepath.Join(demo, ".archlint.yaml") + baselinePath := filepath.Join(t.TempDir(), "baseline.json") + // Back up the original file. origContent, err := os.ReadFile(orderHandlerPath) if err != nil { @@ -155,6 +162,11 @@ func TestE2EScanWithViolations(t *testing.T) { } }) + // Snapshot the CLEAN demo as the delta baseline (floor) BEFORE injecting. + if out, code := run(t, bin, "baseline", internalDir, "--config", configFile, "-o", baselinePath); code != 0 { + t.Fatalf("baseline exited %d on clean code\noutput:\n%s", code, out) + } + // Read step1 file. It starts with //go:build ignore - remove that line. stepContent, err := os.ReadFile(violationStep) if err != nil { @@ -169,12 +181,10 @@ func TestE2EScanWithViolations(t *testing.T) { t.Fatalf("write step1 to handler: %v", err) } - internalDir := filepath.Join(demo, "internal") - configFile := filepath.Join(demo, ".archlint.yaml") - - out, code := run(t, bin, "scan", internalDir, "--config", configFile) + // Scan against the clean baseline: injected layer violation is NEW -> regression -> block. + out, code := run(t, bin, "scan", internalDir, "--config", configFile, "--baseline", baselinePath) if code == 0 { - t.Fatalf("scan exited 0 on violating code (expected non-zero)\noutput:\n%s", out) + t.Fatalf("scan exited 0 on NEW violating code vs baseline (expected non-zero)\noutput:\n%s", out) } if !strings.Contains(out, "FAILED") { @@ -185,7 +195,7 @@ func TestE2EScanWithViolations(t *testing.T) { t.Errorf("expected 'layer-violation' in scan output\noutput:\n%s", out) } - t.Logf("scan detected violation: exit=%d", code) + t.Logf("delta gate blocked NEW layer violation: exit=%d", code) } // TestE2ECollect verifies that collect produces a valid architecture.yaml. diff --git a/tests/fullcycle_test.go b/tests/fullcycle_test.go index 7f51bdd2..6cb5bb59 100644 --- a/tests/fullcycle_test.go +++ b/tests/fullcycle_test.go @@ -26,7 +26,7 @@ func TestFullCycle(t *testing.T) { t.Fatalf("Failed to create output dir: %v", err) } - defer os.RemoveAll(outputDir) + defer func() { _ = os.RemoveAll(outputDir) }() t.Logf("Output directory: %s", outputDir) @@ -45,11 +45,11 @@ func TestFullCycle(t *testing.T) { if err != nil { t.Fatalf("Failed to create arch file: %v", err) } - defer file.Close() + defer func() { _ = file.Close() }() encoder := yaml.NewEncoder(file) encoder.SetIndent(2) - defer encoder.Close() + defer func() { _ = encoder.Close() }() if err := encoder.Encode(graph); err != nil { t.Fatalf("Failed to save architecture: %v", err) diff --git a/validator/__main__.py b/validator/__main__.py index 35c18ee2..86ee335c 100644 --- a/validator/__main__.py +++ b/validator/__main__.py @@ -96,10 +96,10 @@ def load_graph_with_source(filename: str, source: Optional[str] = None): PROFILE_TIERS['all'] = PROFILE_TIERS['research'] DEFAULT_PROFILE = 'fast' # Guard: research-tier на гигантском графе вешает процесс. Skip+warn если V больше -# порога без --force (план Мудреца п.4). Промотированные (slow/fast) метрики не трогаются. +# порога без --force (дизайн-решение п.4). Промотированные (slow/fast) метрики не трогаются. RESEARCH_NODE_GUARD = 2000 -# TIER_OVERRIDE — per-metric классификация ПОВЕРХ группового tier (источник: Мудрец, +# TIER_OVERRIDE — per-metric классификация ПОВЕРХ группового tier (источник: дизайн-решение, # archlint-metrics-audit). Поднимает дешёвые-и-архитектурно-полезные метрики из # research-модулей в дефолт/extended; тяжёлое и семантически-шумное остаётся research. TIER_OVERRIDE = { diff --git a/validator/config.py b/validator/config.py index 9f1769a5..5867b020 100644 --- a/validator/config.py +++ b/validator/config.py @@ -549,10 +549,8 @@ class Config: )) # Homotopy Theory - fundamental_group: RuleConfig = field(default_factory=lambda: RuleConfig( - threshold=10, # Максимальный rank π₁ - error_on_violation=True - )) + # DR-0003/DR-0008: fundamental_group снесён (тождественный алиас β₁, см. betti_numbers). + # Мёртвый конфиг — ровно то, с чем archlint борется, поэтому удалён, а не оставлен. covering_space: RuleConfig = field(default_factory=lambda: RuleConfig( enabled=True, # INFO метрика error_on_violation=False diff --git a/validator/structure/research/advanced_topology_metrics.py b/validator/structure/research/advanced_topology_metrics.py index 74fed3ca..fe2dd542 100644 --- a/validator/structure/research/advanced_topology_metrics.py +++ b/validator/structure/research/advanced_topology_metrics.py @@ -972,87 +972,10 @@ def validate_ricci_flow( # ============================================================================= # HOMOTOPY THEORY # ============================================================================= - -def validate_fundamental_group( - graph: nx.DiGraph, - config: Optional['RuleConfig'] = None -) -> Dict[str, Any]: - """ - Fundamental Group π₁ - фундаментальная группа графа. - - π₁(G) = свободная группа с rank = β₁ = E - V + 1 (для связного) - - Rank π₁ = количество независимых циклов = cyclomatic complexity. - """ - max_rank = 10 - if config and config.threshold is not None: - max_rank = int(config.threshold) - exclude = config.exclude if config else [] - error_on_violation = config.error_on_violation if config else True - - try: - nodes = [n for n in graph.nodes() if not _is_excluded(n, exclude)] - subgraph = graph.subgraph(nodes).to_undirected() - - n = len(nodes) - e = subgraph.number_of_edges() - c = nx.number_connected_components(subgraph) - - if n < 2: - return { - 'name': 'fundamental_group', - 'status': 'SKIP', - 'reason': 'Недостаточно узлов' - } - - # rank π₁ = β₁ = E - V + C - rank_pi1 = e - n + c - - # Находим базис циклов (генераторы π₁) - try: - cycle_basis = nx.cycle_basis(subgraph) - generators = [{'cycle': cyc[:5] + ['...'] if len(cyc) > 5 else cyc, - 'length': len(cyc)} - for cyc in cycle_basis[:10]] - except: - generators = [] - - # Анализ длин циклов - if cycle_basis: - cycle_lengths = [len(c) for c in cycle_basis] - avg_cycle_length = np.mean(cycle_lengths) - min_cycle_length = min(cycle_lengths) - max_cycle_length = max(cycle_lengths) - else: - avg_cycle_length = 0 - min_cycle_length = 0 - max_cycle_length = 0 - - # Контрактируемость: π₁ = 0 ⟺ граф - дерево - is_contractible = (rank_pi1 == 0) - - if rank_pi1 > max_rank: - status = _get_violation_status(error_on_violation) - else: - status = 'PASSED' - - return { - 'name': 'fundamental_group', - 'description': f'rank(π₁) <= {max_rank}', - 'status': status, - 'rank_pi1': rank_pi1, - 'threshold': max_rank, - 'is_contractible': is_contractible, - 'generators_count': len(cycle_basis) if cycle_basis else 0, - 'generators_sample': generators[:5], - 'avg_cycle_length': round(avg_cycle_length, 2), - 'cycle_length_range': [min_cycle_length, max_cycle_length], - 'interpretation': 'rank π₁ = cyclomatic complexity' - } - except Exception as e: - return {'name': 'fundamental_group', 'status': 'ERROR', 'error': str(e)} - - +# DR-0003 (схлопывание π₁→β₁): validate_fundamental_group УДАЛЁН как тождественный +# алиас. Для графа π₁ свободна, rank(π₁) = β₁ = E − V + C — ровно то, что считает +# validate_betti_numbers (topology_metrics.py). Анти-редундантность: archlint не +# содержит метрику-дубль. Если rank(π₁) где-то нужен — это betti β₁, без отдельного расчёта. def validate_covering_space( graph: nx.DiGraph, config: Optional['RuleConfig'] = None diff --git a/validator/structure/research/information_theory_metrics.py b/validator/structure/research/information_theory_metrics.py index 29f91d9f..65273c12 100644 --- a/validator/structure/research/information_theory_metrics.py +++ b/validator/structure/research/information_theory_metrics.py @@ -255,10 +255,9 @@ def validate_channel_capacity( avg_capacity = float(np.mean([c['capacity'] for c in capacities])) if capacities else 0 - if violations: - status = _get_violation_status(error_on_violation) - else: - status = 'PASSED' + # DR-0005: пропускная способность узла (in*out) — дескриптор связности, жёсткого + # арх-порога-принципа нет. Severity всегда INFO (вне боевого гейта). + status = 'INFO' return { 'name': 'channel_capacity', diff --git a/validator/structure/research/linear_algebra_metrics.py b/validator/structure/research/linear_algebra_metrics.py index 2b0b4528..85a1e8dc 100644 --- a/validator/structure/research/linear_algebra_metrics.py +++ b/validator/structure/research/linear_algebra_metrics.py @@ -142,14 +142,13 @@ def validate_condition_number( except Exception: cond = float('inf') + # DR-0005: κ(L)=λ_max/λ_2 — спектральная гетерогенность, порога-принципа нет. + # Дескриптор, не нарушение -> severity всегда INFO (вне боевого гейта). if cond == float('inf'): status = 'INFO' cond_display = 'infinity' - elif cond > max_condition: - status = _get_violation_status(error_on_violation) - cond_display = round(cond, 2) else: - status = 'PASSED' + status = 'INFO' cond_display = round(cond, 2) return { diff --git a/validator/structure/research/test_betti_collapse_golden.py b/validator/structure/research/test_betti_collapse_golden.py new file mode 100644 index 00000000..7cc29bf7 --- /dev/null +++ b/validator/structure/research/test_betti_collapse_golden.py @@ -0,0 +1,46 @@ +"""Golden-тест схлопывания π₁→β₁ (DR-0003) + severity β₁ → INFO (DR-0005). + +Пинит числовую эквивалентность rank(π₁) ≡ β₁ = E − V + C (то, что считал удалённый +validate_fundamental_group, теперь даёт validate_betti_numbers), и «ромб»-контрпример, +доказавший что β₁ НЕ про ориентированные циклы (структурный дескриптор). +""" +import networkx as nx +from validator.structure.research.topology_metrics import validate_betti_numbers + + +def beta1_formula(g: nx.DiGraph) -> int: + """rank(π₁) = β₁ = E − V + C на неориентированной проекции (π₁ свободна для графа).""" + u = g.to_undirected() + return u.number_of_edges() - u.number_of_nodes() + nx.number_connected_components(u) + + +def test_betti_diamond_counterexample(): + # «Ромб» A->B, A->C, B->D, C->D: β₁ = E−V+C = 4−4+1 = 1, при 0 ориент. циклах. + # Доказывает: β₁ — дескриптор формы, НЕ нарушение «нет циклов» (DR-0005). + g = nx.DiGraph([("A", "B"), ("A", "C"), ("B", "D"), ("C", "D")]) + r = validate_betti_numbers(g) + assert r["beta_1"] == 1 + assert r["beta_1"] == beta1_formula(g) # схлопывание: β₁ == формула + assert r["status"] == "INFO" # дескриптор, НЕ ERROR (DR-0005) + assert len(list(nx.simple_cycles(g))) == 0 # 0 ориентированных циклов, а β₁=1 + + +def test_betti_tree_is_zero(): + tree = nx.DiGraph([("r", "a"), ("r", "b"), ("a", "c")]) + r = validate_betti_numbers(tree) + assert r["beta_1"] == 0 == beta1_formula(tree) + assert r["status"] == "INFO" + + +def test_betti_equals_formula_on_cyclic(): + # Три независимых 2-цикла: β₁ должен равняться формуле E−V+C. + g = nx.DiGraph([("1", "2"), ("2", "1"), ("3", "4"), ("4", "3"), ("5", "6"), ("6", "5")]) + r = validate_betti_numbers(g) + assert r["beta_1"] == beta1_formula(g) + assert r["status"] == "INFO" # даже при высоком β₁ — INFO, не гейт + + +def test_fundamental_group_removed(): + # DR-0003: validate_fundamental_group УДАЛЁН (тождественный алиас β₁). + import validator.structure.research.advanced_topology_metrics as atm + assert not hasattr(atm, "validate_fundamental_group") diff --git a/validator/structure/research/topology_metrics.py b/validator/structure/research/topology_metrics.py index d459a2f7..d52da1f6 100644 --- a/validator/structure/research/topology_metrics.py +++ b/validator/structure/research/topology_metrics.py @@ -81,10 +81,10 @@ def validate_betti_numbers( triangles = sum(nx.triangles(subgraph).values()) // 3 beta_2_approx = triangles # Приближение - if beta_1 > max_beta1: - status = _get_violation_status(error_on_violation) - else: - status = 'PASSED' + # DR-0004/DR-0005: β₁ — структурный дескриптор (удалённость графа от дерева), + # НЕ нарушение принципа (контрпример «ромб»: β₁=1 при 0 ориент. циклах ⇒ β₁ не про + # циклы). Severity всегда INFO, вне боевого гейта; цикл-гейт держит validate_dag (core). + status = 'INFO' return { 'name': 'betti_numbers',