From d08019822037a6dfd800768d22da3d5b47efa241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 19 May 2026 10:38:25 +0300 Subject: [PATCH] feat(observer): coverage + registration-integrity controller (C5) Co-Authored-By: Claude Sonnet 4.6 --- docs/observer/STATUS.md | 2 +- tools/observer-coverage-checker.mjs | 90 ++++++++++++++++++++++++ tools/observer-coverage-checker.test.mjs | 45 ++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 tools/observer-coverage-checker.mjs create mode 100644 tools/observer-coverage-checker.test.mjs diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 9809728a..6c453ae3 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-05-19T07:31:46.792Z +Last updated: 2026-05-19T07:35:07.872Z | Контролёр | Состояние | Детали | |---|---|---| diff --git a/tools/observer-coverage-checker.mjs b/tools/observer-coverage-checker.mjs new file mode 100644 index 00000000..09c25c1b --- /dev/null +++ b/tools/observer-coverage-checker.mjs @@ -0,0 +1,90 @@ +#!/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 — recent git commits but 0 observer episodes this month. + * 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: git is invoked via execFileSync (argument array, + * no shell) — no exec/execSync. + */ +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; + +const RECENT_WINDOW = '14 days ago'; + +/** @returns {{ok: boolean, detail: string}} */ +export function checkCoverage(episodeCount, recentCommitCount) { + if (recentCommitCount > 0 && episodeCount === 0) { + return { + ok: false, + detail: `${recentCommitCount} commit(s) in the last 2 weeks but 0 observer episodes this month`, + }; + } + return { ok: true, detail: `${episodeCount} episode(s), ${recentCommitCount} recent commit(s)` }; +} + +/** @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', + }; +} + +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 countRecentCommits(root) { + try { + const out = execFileSync('git', ['log', `--since=${RECENT_WINDOW}`, '--oneline'], { + cwd: root, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return out.trim() ? out.trim().split('\n').length : 0; + } catch { + return 0; + } +} + +export function runCoverageChecker(root = process.cwd()) { + const coverage = checkCoverage(countEpisodes(root), countRecentCommits(root)); + let settings = {}; + try { + settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf-8')); + } catch { + settings = {}; + } + const registration = checkRegistration(settings, existsSync(join(root, '.git', 'hooks', 'post-commit'))); + return { coverage, registration }; +} + +if (process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/observer-coverage-checker.mjs')) { + const { coverage, registration } = 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 (coverage.ok && registration.ok) { + console.log(`[observer-coverage-checker] OK — ${coverage.detail}; ${registration.detail}`); + } + process.exit(0); // warn-only — never blocks a commit +} diff --git a/tools/observer-coverage-checker.test.mjs b/tools/observer-coverage-checker.test.mjs new file mode 100644 index 00000000..641e9634 --- /dev/null +++ b/tools/observer-coverage-checker.test.mjs @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { checkCoverage, checkRegistration } from './observer-coverage-checker.mjs'; + +describe('checkCoverage', () => { + it('flags recent commits but zero episodes', () => { + const r = checkCoverage(0, 7); + expect(r.ok).toBe(false); + expect(r.detail).toContain('0 observer episodes'); + }); + + it('is ok when episodes exist', () => { + expect(checkCoverage(5, 7).ok).toBe(true); + }); + + it('is ok when there is no recent git activity', () => { + expect(checkCoverage(0, 0).ok).toBe(true); + }); +}); + +describe('checkRegistration', () => { + const goodSettings = { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'node tools/observer-stop-hook.mjs' }] }] }, + }; + + it('is ok when the Stop-hook is registered and post-commit exists', () => { + const r = checkRegistration(goodSettings, true); + expect(r.ok).toBe(true); + }); + + it('flags a missing Stop-hook registration', () => { + const r = checkRegistration({ hooks: { Stop: [] } }, true); + expect(r.ok).toBe(false); + expect(r.detail).toContain('observer-stop-hook NOT registered'); + }); + + it('flags a missing post-commit hook', () => { + const r = checkRegistration(goodSettings, false); + expect(r.ok).toBe(false); + expect(r.detail).toContain('post-commit'); + }); + + it('handles an empty settings object', () => { + expect(checkRegistration({}, false).ok).toBe(false); + }); +});