// Copyright (c) 2026 Hellmai Ltd
// SPDX-License-Identifier: AGPL-3.0-only
/**
 * Orchestrate Monitor — Quality Signal (WU-2667)
 *
 * Complements WU-2650 (at-merge commit-order gate) by catching the same
 * TDD violation mid-execution. For each active worktree, walks the lane
 * branch's commits vs the shared base and emits a non-blocking
 * `quality:tests-missing` signal when production LOC exceeds the configured
 * threshold with zero test-file commits past the grace window.
 *
 * This module is the pure core: the integration function accepts an injected
 * commit resolver and clock so tests can drive it with real git fixtures or
 * synthetic commits. See runQualityDetectionForActiveWorktrees for the shape
 * that orchestrate-monitor.ts calls.
 *
 * @module orchestrate-monitor-quality
 */
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { TEST_FILE_PATTERNS } from '@lumenflow/core';
import { BRANCHES, GIT_REFS } from '@lumenflow/core/wu-constants';
import { createSignal } from '@lumenflow/memory/signal';
/**
 * Default minimum production LOC required to trigger a signal. Chosen to match
 * the WU-2650 gate so operators see the same threshold both mid-execution and
 * at merge time unless they override one.
 */
export const DEFAULT_MONITOR_QUALITY_MIN_RUNTIME_LOC = 50;
/**
 * Default grace window in minutes. Agents routinely land the first prod commit
 * a few minutes after claim; the grace window absorbs that normal churn so the
 * signal only fires on genuinely stalled TDD.
 */
export const DEFAULT_MONITOR_QUALITY_GRACE_MINUTES = 15;
/**
 * Canonical signal type emitted by this detector. Orchestrators filter inbox
 * signals on this type via the existing mem:inbox command.
 */
export const MONITOR_QUALITY_SIGNAL_TYPE = 'quality:tests-missing';
const MS_PER_MINUTE = 60 * 1000;
function isPositiveInteger(value) {
    return typeof value === 'number' && Number.isInteger(value) && value > 0;
}
function isNonNegativeInteger(value) {
    return typeof value === 'number' && Number.isInteger(value) && value >= 0;
}
function readBool(value, fallback) {
    return typeof value === 'boolean' ? value : fallback;
}
/**
 * Read `software_delivery.agents.monitor_quality_signals` from the resolved
 * workspace config, applying defaults and coercing invalid values back to
 * defaults so a broken workspace.yaml never silently disables the detector.
 */
export function readMonitorQualitySignalsConfig(config) {
    const defaults = {
        enabled: true,
        min_runtime_loc: DEFAULT_MONITOR_QUALITY_MIN_RUNTIME_LOC,
        grace_minutes: DEFAULT_MONITOR_QUALITY_GRACE_MINUTES,
    };
    if (typeof config !== 'object' || config === null) {
        return defaults;
    }
    const sd = config.software_delivery;
    if (typeof sd !== 'object' || sd === null) {
        return defaults;
    }
    const agents = sd.agents;
    if (typeof agents !== 'object' || agents === null) {
        return defaults;
    }
    const raw = agents.monitor_quality_signals;
    if (typeof raw !== 'object' || raw === null) {
        return defaults;
    }
    const candidate = raw;
    return {
        enabled: readBool(candidate.enabled, defaults.enabled),
        min_runtime_loc: isPositiveInteger(candidate.min_runtime_loc)
            ? candidate.min_runtime_loc
            : defaults.min_runtime_loc,
        grace_minutes: isNonNegativeInteger(candidate.grace_minutes)
            ? candidate.grace_minutes
            : defaults.grace_minutes,
    };
}
function normalisePath(filePath) {
    return filePath.replace(/\\/g, '/');
}
function isTestFilePath(filePath) {
    const normalized = normalisePath(filePath);
    return TEST_FILE_PATTERNS.some((pattern) => pattern.test(normalized));
}
/**
 * Documentation-only path prefixes and extensions. Kept intentionally narrow
 * and static so the detector never depends on project-root lookup during a
 * monitor tick — the monitor runs across multiple worktrees and any per-WU
 * config resolution would be a hot-path hazard.
 */
const DOC_PATH_PREFIXES = ['docs/', 'apps/docs/', 'ai/', '.claude/', 'memory-bank/'];
function isDocPath(filePath) {
    const normalized = normalisePath(filePath).toLowerCase();
    if (normalized.endsWith('.md') || normalized.endsWith('.mdx')) {
        return true;
    }
    return DOC_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
function isRuntimeFilePath(filePath) {
    if (isTestFilePath(filePath)) {
        return false;
    }
    // Documentation-only paths (docs/, *.md, etc.) don't count as production code.
    if (isDocPath(filePath)) {
        return false;
    }
    return true;
}
/**
 * Pure violation detector. Returns `violator: true` when:
 *  - total non-test, non-doc LOC added across runtime commits >= minRuntimeLoc
 *  - at least one runtime commit exists
 *  - no commit touched a test file
 *  - wall-clock time since `claimedAt` exceeds graceMinutes (grace is waived
 *    when `claimedAt` is missing; we fall back to "detect on LOC alone").
 */
export function detectTestsMissingForWorktree(input) {
    const { commits, minRuntimeLoc, graceMinutes, claimedAt, now } = input;
    let productionLoc = 0;
    let firstProductionCommit;
    let sawTestCommit = false;
    for (const commit of commits) {
        const hasTestInCommit = commit.files.some((file) => isTestFilePath(file.path));
        if (hasTestInCommit) {
            sawTestCommit = true;
        }
        const runtimeAdded = commit.files.reduce((sum, file) => (isRuntimeFilePath(file.path) ? sum + file.added : sum), 0);
        if (runtimeAdded > 0 && !firstProductionCommit) {
            firstProductionCommit = { hash: commit.hash, subject: commit.subject };
        }
        productionLoc += runtimeAdded;
    }
    if (sawTestCommit) {
        return { violator: false, productionLoc, withinGrace: false };
    }
    if (productionLoc < minRuntimeLoc || !firstProductionCommit) {
        return { violator: false, productionLoc, withinGrace: false };
    }
    // Respect the grace window: agents routinely land the first prod commit a
    // few minutes after claim; warning during that window is noise.
    const claimedAtMs = claimedAt ? Date.parse(claimedAt) : NaN;
    if (!Number.isNaN(claimedAtMs)) {
        const elapsedMs = now.getTime() - claimedAtMs;
        if (elapsedMs < graceMinutes * MS_PER_MINUTE) {
            return { violator: false, productionLoc, withinGrace: true, firstProductionCommit };
        }
    }
    return { violator: true, productionLoc, withinGrace: false, firstProductionCommit };
}
/**
 * Default commit resolver: `git log main..HEAD --numstat`. Matches the
 * invocation wu-prep.ts uses at merge time so the same parsing path applies.
 */
export function defaultResolveBranchCommitsSync(options) {
    try {
        // `git` is a first-class LumenFlow runtime dependency; other orchestrate
        // modules resolve it from PATH the same way.
        // eslint-disable-next-line sonarjs/no-os-command-from-path
        let baseRef = GIT_REFS.ORIGIN_MAIN;
        try {
            // eslint-disable-next-line sonarjs/no-os-command-from-path
            execFileSync('git', ['rev-parse', '--verify', baseRef], {
                cwd: options.cwd,
                stdio: 'pipe',
            });
        }
        catch {
            baseRef = BRANCHES.MAIN;
            // eslint-disable-next-line sonarjs/no-os-command-from-path
            execFileSync('git', ['rev-parse', '--verify', baseRef], {
                cwd: options.cwd,
                stdio: 'pipe',
            });
        }
        const raw = execFileSync(
        // eslint-disable-next-line sonarjs/no-os-command-from-path
        'git', [
            'log',
            `${baseRef}..HEAD`,
            '--reverse',
            '--numstat',
            '--format=__WU_COMMIT__%H%x09%s',
            '--no-merges',
        ], { cwd: options.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
        return { ok: true, commits: parseBranchCommits(raw) };
    }
    catch (error) {
        return { ok: false, reason: error instanceof Error ? error.message : String(error) };
    }
}
/**
 * Parse `git log --numstat --format=__WU_COMMIT__<hash>\t<subject>` output.
 * Binary files report `-` for added/deleted; those contribute 0 LOC.
 */
export function parseBranchCommits(raw) {
    const commits = [];
    let current = null;
    for (const rawLine of raw.split('\n')) {
        const line = rawLine.trimEnd();
        if (line.startsWith('__WU_COMMIT__')) {
            if (current)
                commits.push(current);
            const header = line.slice('__WU_COMMIT__'.length);
            const tab = header.indexOf('\t');
            const hash = tab === -1 ? header : header.slice(0, tab);
            const subject = tab === -1 ? '' : header.slice(tab + 1);
            current = { hash, subject, files: [] };
            continue;
        }
        if (!current || line.length === 0)
            continue;
        const parts = line.split('\t');
        if (parts.length < 3)
            continue;
        const [addedRaw, deletedRaw, ...pathParts] = parts;
        const filePath = pathParts.join('\t');
        const added = addedRaw === '-' ? 0 : Number.parseInt(addedRaw, 10);
        const deleted = deletedRaw === '-' ? 0 : Number.parseInt(deletedRaw, 10);
        if (Number.isNaN(added) || Number.isNaN(deleted) || !filePath)
            continue;
        current.files.push({ path: filePath, added, deleted });
    }
    if (current)
        commits.push(current);
    return commits;
}
function buildSignalMessage(wuId, productionLoc, threshold) {
    return (`quality:tests-missing — ${wuId}: ${productionLoc} runtime LOC committed ` +
        `(threshold ${threshold}) with zero test commits on the lane branch.`);
}
/**
 * Run the detector across every active worktree and emit a signal for each
 * violator. Returns the emitted signals so the monitor can surface them in
 * its output; the canonical delivery channel is the appended
 * `.lumenflow/memory/signals.jsonl` line (observable via mem:inbox).
 */
export async function runQualityDetectionForActiveWorktrees(options) {
    const { baseDir, now, policy, activeWorktrees } = options;
    if (!policy.enabled) {
        return [];
    }
    const resolve = options.resolveBranchCommits ?? defaultResolveBranchCommitsSync;
    const emissions = [];
    for (const wt of activeWorktrees) {
        const cwd = join(baseDir, wt.worktreePath);
        const commitsResult = resolve({ cwd });
        if (!commitsResult.ok) {
            continue; // Silently skip worktrees we can't inspect — not our job to fail the monitor.
        }
        const detection = detectTestsMissingForWorktree({
            commits: commitsResult.commits,
            minRuntimeLoc: policy.min_runtime_loc,
            graceMinutes: policy.grace_minutes,
            claimedAt: wt.claimedAt,
            now,
        });
        if (!detection.violator) {
            continue;
        }
        const message = buildSignalMessage(wt.wuId, detection.productionLoc, policy.min_runtime_loc);
        const { signal } = await createSignal(baseDir, {
            message,
            wuId: wt.wuId,
            lane: wt.lane,
            type: MONITOR_QUALITY_SIGNAL_TYPE,
            sender: 'orchestrate:monitor',
            origin: 'local',
        });
        emissions.push({
            wuId: wt.wuId,
            lane: wt.lane,
            productionLoc: detection.productionLoc,
            firstProductionCommit: detection.firstProductionCommit,
            signal,
        });
    }
    return emissions;
}
//# sourceMappingURL=orchestrate-monitor-quality.js.map