# Sub-plan F — радары полноты исполнения Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (или executing-plans) task-by-task. Steps — чек-боксы (`- [ ]`).

**Goal:** Чистые модули `tools/graph-radar.mjs` + `tools/skeleton-radar.mjs` — две ступени «лестницы радаров» ДР-3 (§5.7 спеки) как проверка ПОЛНОТЫ исполнения (§8 Q3): **граф-радар** (тронул файл X → граф связывает с Y → адресован ли Y?) и **скелет-радар** (grep заголовков/сигнатур → Claude отчитывается по КАЖДОМУ пункту: затронуто / не релевантно / не читал).

**Architecture:** Чистые ядра по образцу `tools/project-graph.mjs` / `tools/context-verity.mjs` — никакого прямого I/O; граф (выход `parseGraph` из sub-plan B) и список заголовков ИНЪЕКТИРУЮТСЯ. Радары — на ФАЗЕ ИСПОЛНЕНИЯ (после заморозки, во время action): дают контроллеру/наставнику чек-лист «что ещё проверить». Wiring в исполнение — sub-plan C2 / поведенческий (контроллер зовёт радар после шага). Решение владельца 2026-06-10: радары = КОД (закрывает нах.F2, ранее в лимбо «код или поведение»).

**Tech Stack:** Node ESM `.mjs`, vitest (tools-config, ТОЛЬКО из `app/`: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs <фильтр> --reporter dot`; cwd ненадёжен → префиксить `cd app &&`).

**Источник истины:** спека R6.3 §5.7 (лестница радаров) + §8 Q3 (проверка полноты) + ДР-3. Граф — формат B: `{nodes:[{id,label,source_file,...}], links:[{source,target,...}]}` (`source`/`target` = node `id`).

**Scope:** `tools/graph-radar.mjs` + `tools/skeleton-radar.mjs` (+ тесты). **НЕ в F:** wiring в исполнение (C2/поведенческий); реальный grep заголовков (caller грепает, передаёт `headers`); построение нод-графа (B).

**NB git:** git зажат стеной у Claude — коммиты выполняет владелец.

---

### Task 1: `buildGraphRadar` — граф-радар (тронул X → соседи Y → адресован?)

**Files:** Create: `tools/graph-radar.mjs`. Test: `tools/graph-radar.test.mjs`.

**§5.7 граф-радар (файл-уровень).** Для каждого ТРОНУТОГО файла → его узлы в графе → соседи по `links` → чек-лист «граф связывает тронутый X с Y — адресован ли Y?». Чистый (граф инъектируется). `addressed:false` по умолчанию — контроллер/наставник отмечает.

- [ ] **Step 1: Write the failing test**

```js
// tools/graph-radar.test.mjs
import { describe, it, expect } from 'vitest';
import { buildGraphRadar } from './graph-radar.mjs';

const graph = {
  nodes: [
    { id: 'x', label: 'X', source_file: 'tools/x.mjs' },
    { id: 'y', label: 'Y', source_file: 'tools/y.mjs' },
    { id: 'z', label: 'Z', source_file: 'tools/z.mjs' },
  ],
  links: [
    { source: 'x', target: 'y', relation: 'references' },
    { source: 'z', target: 'x', relation: 'references' },
  ],
};

describe('buildGraphRadar (§5.7 граф-радар)', () => {
  it('тронут x → соседи y и z в чек-листе (addressed:false)', () => {
    const r = buildGraphRadar({ touchedFiles: ['tools/x.mjs'], graph });
    const ids = r.neighbors.map((n) => n.node).sort();
    expect(ids).toEqual(['y', 'z']);
    expect(r.neighbors.every((n) => n.addressed === false)).toBe(true);
    expect(r.neighbors.find((n) => n.node === 'y').viaTouched).toBe('x');
  });
  it('сосед, который САМ тронут, не попадает в чек-лист (уже адресован)', () => {
    const r = buildGraphRadar({ touchedFiles: ['tools/x.mjs', 'tools/y.mjs'], graph });
    expect(r.neighbors.map((n) => n.node)).toEqual(['z']); // y тронут → не флагуем
  });
  it('нет тронутых узлов в графе → пустой чек-лист', () => {
    expect(buildGraphRadar({ touchedFiles: ['tools/nope.mjs'], graph }).neighbors).toEqual([]);
  });
  it('битый граф/входы → пусто, не крашит', () => {
    expect(buildGraphRadar({ touchedFiles: null, graph: null }).neighbors).toEqual([]);
    expect(buildGraphRadar({ touchedFiles: ['a'], graph: { nodes: 'x', links: 1 } }).neighbors).toEqual([]);
  });
});
```

- [ ] **Step 2: Run — FAIL** (`Cannot find module './graph-radar.mjs'`).

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs ../tools/graph-radar.test.mjs --reporter dot`

- [ ] **Step 3: Write minimal implementation**

```js
// tools/graph-radar.mjs
#!/usr/bin/env node
/**
 * graph-radar (§5.7 ДР-3) — файл-уровень проверки полноты: тронул X → граф связывает
 * с Y → адресован ли Y? Чистое ядро (граф инъектируется, формат project-graph B).
 * Сосед, который сам в touchedFiles, не флагуется (уже адресован).
 */
export function buildGraphRadar({ touchedFiles, graph } = {}) {
  const touched = Array.isArray(touchedFiles) ? touchedFiles : [];
  const nodes = graph && Array.isArray(graph.nodes) ? graph.nodes : [];
  const links = graph && Array.isArray(graph.links) ? graph.links : [];
  const touchedSet = new Set(touched);
  // id → узел (для source_file соседа)
  const byId = new Map(nodes.map((n) => [n && n.id, n]).filter(([id]) => id));
  // id тронутых узлов
  const touchedIds = new Set(nodes.filter((n) => n && touchedSet.has(n.source_file)).map((n) => n.id));
  const seen = new Set();
  const neighbors = [];
  for (const l of links) {
    if (!l || typeof l !== 'object') continue;
    for (const [a, b] of [[l.source, l.target], [l.target, l.source]]) {
      if (!touchedIds.has(a)) continue;          // a — тронутый конец
      const nb = byId.get(b);
      if (!nb) continue;
      if (touchedSet.has(nb.source_file)) continue; // сосед сам тронут → адресован
      const key = `${b}<-${a}`;
      if (seen.has(key)) continue;
      seen.add(key);
      neighbors.push({ node: b, label: nb.label ?? null, source_file: nb.source_file ?? null, viaTouched: a, addressed: false });
    }
  }
  return { neighbors, summary: { touchedNodes: touchedIds.size, unaddressed: neighbors.length } };
}
```

- [ ] **Step 4: Run — PASS** (4 passed).
- [ ] **Step 5: Commit** `feat(graph-radar): граф-радар полноты (нах.F2, sub-plan F Task 1)`.

---

### Task 2: `buildSkeletonRadar` — скелет-радар (отчёт по каждому заголовку)

**Files:** Create: `tools/skeleton-radar.mjs`. Test: `tools/skeleton-radar.test.mjs`.

**§5.7 скелет-радар (раздел-уровень).** grep заголовков/сигнатур файла (инъектируются как `headers`); Claude отчитывается по КАЖДОМУ пункту: `touched` / `not-relevant` / `not-read`. Радар строит ЛЕСА: непрорепорченные пункты → `not-reported` (требуют отчёта).

- [ ] **Step 1: Write the failing test**

```js
// tools/skeleton-radar.test.mjs
import { describe, it, expect } from 'vitest';
import { buildSkeletonRadar, REPORT_STATUSES } from './skeleton-radar.mjs';

describe('buildSkeletonRadar (§5.7 скелет-радар)', () => {
  const headers = ['## Auth', '## Billing', '## Webhooks'];
  it('каждый заголовок → пункт; непрорепорченный → not-reported', () => {
    const r = buildSkeletonRadar({ headers, reports: { '## Auth': 'touched' } });
    expect(r.items).toHaveLength(3);
    expect(r.items.find((i) => i.header === '## Auth').status).toBe('touched');
    expect(r.items.find((i) => i.header === '## Billing').status).toBe('not-reported');
    expect(r.unreported.map((i) => i.header).sort()).toEqual(['## Billing', '## Webhooks']);
  });
  it('REPORT_STATUSES заморожен и содержит 4 статуса', () => {
    expect(Object.isFrozen(REPORT_STATUSES)).toBe(true);
    expect(REPORT_STATUSES).toEqual(['touched', 'not-relevant', 'not-read', 'not-reported']);
  });
  it('невалидный статус в reports → not-reported (fail-safe)', () => {
    const r = buildSkeletonRadar({ headers: ['## A'], reports: { '## A': 'выдумка' } });
    expect(r.items[0].status).toBe('not-reported');
  });
  it('битые входы → пусто, не крашит', () => {
    expect(buildSkeletonRadar({ headers: null }).items).toEqual([]);
    expect(buildSkeletonRadar({}).unreported).toEqual([]);
  });
});
```

- [ ] **Step 2: Run — FAIL** (`Cannot find module './skeleton-radar.mjs'`).

- [ ] **Step 3: Write minimal implementation**

```js
// tools/skeleton-radar.mjs
#!/usr/bin/env node
/**
 * skeleton-radar (§5.7 ДР-3) — раздел-уровень: grep заголовков → Claude отчитывается по
 * КАЖДОМУ (touched/not-relevant/not-read). Чистое ядро (headers + reports инъектируются).
 * Непрорепорченный ИЛИ невалидно-прорепорченный пункт → not-reported (fail-safe: молчание
 * = «не отчитался», а не «не релевантно»).
 */
export const REPORT_STATUSES = Object.freeze(['touched', 'not-relevant', 'not-read', 'not-reported']);
const VALID = new Set(['touched', 'not-relevant', 'not-read']);

export function buildSkeletonRadar({ headers, reports } = {}) {
  const list = Array.isArray(headers) ? headers : [];
  const rep = (reports && typeof reports === 'object') ? reports : {};
  const items = list.map((h) => {
    const s = rep[h];
    return { header: h, status: VALID.has(s) ? s : 'not-reported' };
  });
  return { items, unreported: items.filter((i) => i.status === 'not-reported') };
}
```

- [ ] **Step 4: Run — PASS** (4 passed).
- [ ] **Step 5: Commit** `feat(skeleton-radar): скелет-радар полноты (нах.F2, sub-plan F Task 2)`.

---

### Task 3: Регрессия + нота wiring

**Files:** (нет правок — проверка)

- [ ] **Step 1: Прогнать ВЕСЬ tools-набор** [НЕ-TDD: регрессия]

Run: `cd app && node node_modules/vitest/vitest.mjs run --config vitest.config.tools.mjs --reporter dot`
Expected: PASS — `graph-radar.test.mjs` (4) + `skeleton-radar.test.mjs` (4) включены, 0 регрессий М1–М7 + соседних sub-plan. Зафиксировать число passed.

- [ ] **Step 2: Нота wiring (doc).** Радары зовутся на ФАЗЕ ИСПОЛНЕНИЯ как проверка полноты (§8 Q3): после action-шага контроллер строит `buildGraphRadar({touchedFiles: изменённые файлы, graph})` + `buildSkeletonRadar({headers: grep файла, reports: отчёт Claude})` → чек-лист «что ещё проверить». Wiring — C2/поведенческий (не F). Граф берётся из B (`parseGraph` живого graphify-out).

- [ ] **Step 3: Если RED — systematic-debugging**, точечный фикс, повтор.

---

## Self-Review (writing-plans)

**1. Spec coverage:** §5.7 граф-радар → Task 1. §5.7 скелет-радар → Task 2. §8 Q3 проверка полноты → нота wiring Task 3. нах.F2 (радары как код, решение владельца) → Task 1/2. ✅

**2. Placeholder scan:** реальный код+тесты в каждом шаге; команды Run с ожидаемым выводом; нет «TBD». ✅

**3. Type consistency:** `buildGraphRadar`→`{neighbors:[{node,label,source_file,viaTouched,addressed}], summary}`; `buildSkeletonRadar`→`{items:[{header,status}], unreported}`; `REPORT_STATUSES` frozen. ✅

**Известное ограничение (честно):** радары дают ЧЕК-ЛИСТ (что проверить), отметку «адресовано/затронуто» ставит контроллер/наставник/владелец — машина не решает «достаточно ли». Реальный grep заголовков (Task 2 `headers`) и список изменённых файлов (Task 1 `touchedFiles`) — забота caller'а (C2/поведенческий wiring), не F. Это сокращение пропусков (L2-пол), не обнуление.
