Files
portal/tools/observer-coverage-checker.mjs
T
Дмитрий 8f00449615 fix сторож наблюдателя: не кричим про пропавший хук, когда он на месте
Работа 1 промта от 01.08 — оживление сторожей текста. По ходу вскрылся
отдельный дефект в нашем собственном скрипте.

observer-coverage-checker при коммите из рабочей папки ветки печатал
".git/hooks/post-commit not installed". Файл при этом лежал на месте.
Причина: в рабочей папке ветки .git не папка, а файл-указатель, и проверка
пути <корень>/.git/hooks/post-commit смотрела мимо цели. Плюс настройка
core.hooksPath, которая в этом репозитории задана, вообще не учитывалась.

Класс беды тот же, что уже дважды записан в память: сторож, не умеющий
отличить "нет" от "лежит в другом месте", штампует ложные тревоги. Рядом
в той же строке живёт правдивая жалоба про неподключённый обработчик - и
ложная половина приучает не читать всю строку целиком.

Что сделано:
- добавлена postCommitInstalled: сначала ищет общую папку репозитория
  (для обычной копии это сам .git, для рабочей папки ветки - по указателю
  gitdir и файлу commondir рядом с ним), затем учитывает core.hooksPath
  из config и только потом смотрит наличие файла;
- чистое чтение файлов, без запуска сторонних команд - требование
  Security Guidance 40 для этого скрипта соблюдено.

Порядок работы соблюдён. Пять проверок написаны ДО правки и покраснели
на отсутствующей функции. Разобраны обе стороны каждой развилки: обычная
копия с хуком и без, рабочая папка ветки с хуком и без, перенос хуков
настройкой. Живой прогон по настоящему репозиторию: из рабочей папки
ветки ложная половина ушла, правдивая осталась.

Прогоны: набор проверок инструментов 14 файлов, 66 проверок, все зелёные.

Отдельно, без правок в git: node_modules был в состоянии оборванной
установки - 121 временная папка распаковки, 90 пакетов без главного файла,
у tinyglobby файл обрезан посреди строки. Причина обрыва - падение сборки
better-sqlite3. Пересобрано начисто из списка версий без сборочных
сценариев; package.json и package-lock.json не тронуты, проверено хешем.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:19:10 +03:00

194 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* C5 observer-coverage-checker (brain governance, observer factor-analysis
* spec §5.2). Warn-only — always exits 0. Two checks:
* 1. Coverage — Stop-hook is registered but 0 episodes this month.
* Comparing episodes against commit volume is wrong-unit (commits =
* work-unit, episodes = turn-unit) and wrong-window (the C5 window
* can predate the hook's registration); a freshly-registered hook
* vs. 1000 historical commits would flap forever. Driven by hook
* registration instead — the only honest expectation source.
* 2. Registration integrity — observer Stop-hook present in
* .claude/settings.json and .git/hooks/post-commit installed.
* Findings are surfaced in docs/observer/STATUS.md (C4 generator); this
* controller never blocks a commit.
*
* Security Guidance #40: pure fs — no exec/execSync.
*/
import { readFileSync, existsSync, statSync } from 'fs';
import { join, resolve } from 'path';
import { detectMissedActivations } from './missed-activations.mjs';
import { dedupeEpisodes } from './brain-retro-analyzer.mjs';
import { loadRegistry } from './registry-load.mjs';
import { buildClassificationMap, buildDormancyMap } from './registry-to-classification-map.mjs';
/**
* @param {number} episodeCount - episodes in the current month JSONL
* @param {boolean} hookRegistered - whether observer-stop-hook is wired in settings
* @returns {{ok: boolean, detail: string}}
*/
export function checkCoverage(episodeCount, hookRegistered) {
if (hookRegistered && episodeCount === 0) {
return {
ok: false,
detail: `Stop-hook registered but 0 episode(s) recorded this month — hook may be silently failing`,
};
}
return { ok: true, detail: `${episodeCount} episode(s) this month` };
}
/** @returns {{ok: boolean, detail: string}} */
export function checkRegistration(settingsJson, postCommitExists) {
const problems = [];
const stopHooks = (((settingsJson || {}).hooks || {}).Stop) || [];
const hasObserverStop = stopHooks.some((entry) =>
((entry && entry.hooks) || []).some((h) => String((h && h.command) || '').includes('observer-stop-hook'))
);
if (!hasObserverStop) {
problems.push('observer-stop-hook NOT registered in .claude/settings.json Stop hook');
}
if (!postCommitExists) {
problems.push('.git/hooks/post-commit not installed (run: npx lefthook install --force)');
}
return {
ok: problems.length === 0,
detail: problems.length ? problems.join('; ') : 'Stop-hook + post-commit OK',
};
}
/**
* Общая папка репозитория. В обычной рабочей копии это сам `.git`; в рабочей
* папке ветки (git worktree) `.git` — файл-указатель `gitdir: <путь>`, а рядом
* с указанной папкой лежит `commondir` с дорогой до общей папки репозитория.
* @param {string} root
* @returns {string|null}
*/
function gitCommonDir(root) {
const dot = join(root, '.git');
if (!existsSync(dot)) return null;
let st;
try { st = statSync(dot); } catch { return null; }
if (st.isDirectory()) return dot;
let gitdir;
try {
const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(dot, 'utf-8'));
if (!m) return null;
gitdir = resolve(root, m[1].trim());
} catch { return null; }
const common = join(gitdir, 'commondir');
if (!existsSync(common)) return gitdir;
try { return resolve(gitdir, readFileSync(common, 'utf-8').trim()); } catch { return gitdir; }
}
/**
* Настройка core.hooksPath переносит папку хуков в другое место.
* @returns {string|null}
*/
function hooksPathFromConfig(commonDir, root) {
const cfg = join(commonDir, 'config');
if (!existsSync(cfg)) return null;
try {
const m = /^\s*hooksPath\s*=\s*(.+)$/m.exec(readFileSync(cfg, 'utf-8'));
return m ? resolve(root, m[1].trim()) : null;
} catch { return null; }
}
/**
* Установлен ли хук post-commit. Учитывает рабочие папки веток и core.hooksPath —
* иначе из worktree сторож штампует ложную тревогу «post-commit not installed».
* @param {string} root
* @returns {boolean}
*/
export function postCommitInstalled(root) {
const commonDir = gitCommonDir(root);
if (!commonDir) return false;
const hooksDir = hooksPathFromConfig(commonDir, root) || join(commonDir, 'hooks');
return existsSync(join(hooksDir, 'post-commit'));
}
function countEpisodes(root) {
const month = new Date().toISOString().slice(0, 7);
const file = join(root, 'docs', 'observer', `episodes-${month}.jsonl`);
if (!existsSync(file)) return 0;
return readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean).length;
}
function loadEpisodes(root) {
const month = new Date().toISOString().slice(0, 7);
const file = join(root, 'docs', 'observer', `episodes-${month}.jsonl`);
if (!existsSync(file)) return [];
const out = [];
for (const line of readFileSync(file, 'utf-8').split('\n')) {
const t = line.trim();
if (!t) continue;
try { out.push(JSON.parse(t)); } catch { /* skip */ }
}
return out;
}
function loadClassificationMap(root) {
try {
const registry = loadRegistry({
registryPath: join(root, 'docs', 'registry', 'nodes.yaml'),
schemaPath: join(root, 'docs', 'registry', 'schema.json'),
useCache: false,
});
return buildClassificationMap(registry);
} catch { return {}; }
}
function loadDormancy(root) {
try {
const registry = loadRegistry({
registryPath: join(root, 'docs', 'registry', 'nodes.yaml'),
schemaPath: join(root, 'docs', 'registry', 'schema.json'),
useCache: false,
});
return buildDormancyMap(registry);
} catch { return {}; }
}
function readSettings(root) {
try {
return JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf-8'));
} catch {
return {};
}
}
function isObserverStopRegistered(settings) {
const stopHooks = (((settings || {}).hooks || {}).Stop) || [];
return stopHooks.some((entry) =>
((entry && entry.hooks) || []).some((h) =>
String((h && h.command) || '').includes('observer-stop-hook')
)
);
}
export function runCoverageChecker(root = process.cwd()) {
const settings = readSettings(root);
const hookRegistered = isObserverStopRegistered(settings);
const coverage = checkCoverage(countEpisodes(root), hookRegistered);
const registration = checkRegistration(settings, postCommitInstalled(root));
const episodes = loadEpisodes(root).filter((e) => e && e.schema_version === 2 && !e.observer_error);
const missed = detectMissedActivations(
dedupeEpisodes(episodes),
loadClassificationMap(root),
loadDormancy(root)
);
return { coverage, registration, missed };
}
if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-coverage-checker.mjs')) {
const { coverage, registration, missed } = runCoverageChecker();
if (!coverage.ok) console.warn(`[observer-coverage-checker] WARN — coverage: ${coverage.detail}`);
if (!registration.ok) console.warn(`[observer-coverage-checker] WARN — registration: ${registration.detail}`);
if (missed.totalMissed > 0) {
console.warn(`[observer-coverage-checker] WARN — missed activations: ${missed.totalMissed} (see /brain-retro)`);
}
if (coverage.ok && registration.ok && missed.totalMissed === 0) {
console.log(`[observer-coverage-checker] OK — ${coverage.detail}; ${registration.detail}`);
}
process.exit(0); // warn-only — never blocks a commit
}