// engine/detectors/orphan-spec-refs.ts — SPEC-582: Orphan spec ID detector
// Detects commits referencing SPEC-NNN IDs that have no matching planu/specs/ directory.
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
const execFile = promisify(execFileCb);
// Matches feat(SPEC-582): or fix(SPEC-584-slug): — anchored on conventional-commit prefix.
// Group 2 captures the numeric ID only.
const SPEC_SCOPE_REGEX = /(feat|fix|refactor|docs|chore|perf|test|build|ci|revert)(?:\(SPEC-(\d+)(?:-[a-z0-9-]+)?\))/i;
/** Maximum number of commits to scan in 'head' range mode */
const HEAD_COMMIT_LIMIT = 20;
/** Run git and return stdout. Throws on non-zero exit. */
async function runGit(cwd, args) {
    const { stdout } = await execFile('git', args, {
        cwd,
        maxBuffer: 4 * 1024 * 1024,
        timeout: 30_000,
    });
    return stdout;
}
/**
 * Resolve git range arguments based on the requested scan range.
 * Returns the git log argument array excluding format/separator flags.
 */
async function resolveRangeArgs(projectPath, range) {
    if (range === 'head') {
        return [`-${HEAD_COMMIT_LIMIT}`];
    }
    if (range === 'origin-main..head') {
        return ['origin/main..HEAD'];
    }
    // since-last-release: find the most recent v* tag and use it as base
    try {
        const tagOutput = await runGit(projectPath, ['describe', '--tags', '--abbrev=0', '--match=v*']);
        const lastTag = tagOutput.trim();
        if (lastTag) {
            return [`${lastTag}..HEAD`];
        }
    }
    catch {
        // No tags found — fall back to last 20 commits
    }
    return [`-${HEAD_COMMIT_LIMIT}`];
}
/** ASCII record separator used as the commit delimiter in git log output */
const RECORD_SEP = '\x1e';
/** ASCII unit separator used as the field delimiter within a commit record */
const FIELD_SEP = '\x1f';
/**
 * Parse git log output into structured commit records.
 * Expected format per commit: sha FS subject FS body RS
 * where FS = ASCII unit-separator (0x1F) and RS = ASCII record-separator (0x1E).
 */
export function parseGitLogOutput(raw) {
    if (!raw.trim()) {
        return [];
    }
    const records = raw.split(RECORD_SEP).filter((r) => r.trim());
    const commits = [];
    for (const record of records) {
        const parts = record.split(FIELD_SEP);
        const sha = parts[0]?.trim() ?? '';
        const subject = parts[1]?.trim() ?? '';
        const body = parts[2]?.trim() ?? '';
        if (sha && subject) {
            commits.push({ sha, subject, body });
        }
    }
    return commits;
}
/**
 * Fetch commits from git log for the given range.
 * Uses NUL as field separator to avoid escaping issues with special characters.
 */
export async function fetchCommits(projectPath, range) {
    const rangeArgs = await resolveRangeArgs(projectPath, range);
    // Record separator: triple NUL between commits, fields separated by single NUL
    // Use ASCII FS (0x1F) as field separator and RS (0x1E) as record separator.
    // These control characters never appear in commit messages naturally.
    const raw = await runGit(projectPath, ['log', ...rangeArgs, '--format=%H%x1f%s%x1f%b%x1e']);
    return parseGitLogOutput(raw);
}
/**
 * Enumerate existing spec IDs from planu/specs/ directory entries.
 * Returns a Set of numeric IDs extracted from folder name prefixes (SPEC-NNN-*).
 */
export async function enumerateExistingSpecIds(projectPath) {
    const specsDir = join(projectPath, 'planu', 'specs');
    const ids = new Set();
    let entries;
    try {
        entries = await readdir(specsDir);
    }
    catch {
        // planu/specs/ does not exist yet — treat as empty
        return ids;
    }
    for (const entry of entries) {
        const match = /^SPEC-(\d+)/i.exec(entry);
        if (match?.[1] !== undefined) {
            ids.add(parseInt(match[1], 10));
        }
    }
    return ids;
}
/**
 * Extract the SPEC-NNN reference from a single commit subject.
 * Returns the numeric spec ID, or null if no reference is found.
 */
export function extractSpecId(subject) {
    const match = SPEC_SCOPE_REGEX.exec(subject);
    if (!match?.[2]) {
        return null;
    }
    return parseInt(match[2], 10);
}
/**
 * Pure detector function — cross-references parsed commits against known spec IDs.
 * Returns only commits that contain a SPEC-NNN reference (found or missing).
 *
 * @param commits - Commits to analyze
 * @param existingSpecIds - Set of numeric spec IDs that exist in planu/specs/
 */
export function detectOrphanRefs(commits, existingSpecIds) {
    const refs = [];
    const seen = new Set(); // dedup by sha+specId
    for (const commit of commits) {
        const specId = extractSpecId(commit.subject);
        if (specId === null) {
            continue;
        }
        const key = `${commit.sha}:${specId}`;
        if (seen.has(key)) {
            continue;
        }
        seen.add(key);
        refs.push({
            commitSha: commit.sha,
            commitSubject: commit.subject,
            specId,
            status: existingSpecIds.has(specId) ? 'found' : 'missing',
        });
    }
    return refs;
}
/**
 * Run the full orphan detection pipeline for a project.
 * Fetches commits, enumerates specs, and cross-references.
 */
export async function runOrphanDetection(projectPath, range) {
    const [commits, existingSpecIds] = await Promise.all([
        fetchCommits(projectPath, range),
        enumerateExistingSpecIds(projectPath),
    ]);
    const allRefs = detectOrphanRefs(commits, existingSpecIds);
    const orphans = allRefs.filter((r) => r.status === 'missing');
    let humanSummary;
    if (orphans.length === 0) {
        humanSummary = 'No orphan SPEC references found';
    }
    else {
        const ids = orphans.map((o) => `SPEC-${o.specId}`).join(', ');
        humanSummary =
            `Found ${orphans.length} orphan SPEC reference(s): ${ids}. ` +
                `Run scan_orphan_spec_refs with mode=create-retroactive to remediate, ` +
                `or create the missing specs manually with create_spec.`;
    }
    return { orphans, humanSummary };
}
//# sourceMappingURL=orphan-spec-refs.js.map