/**
 * commands/audit.mjs — policy-driven privacy audit + visibility-signals (C35)
 * + artifact-lifecycle drift audit (C56 B2).
 *
 * Three deterministic subcommands:
 *
 *   apt-tools audit privacy <project-dir> [--scrub]
 *       Loads .aperant/config.json → resolveSharePolicy → buildPolicyIgnore
 *       Matcher. Runs `git ls-files -z -- .aperant/` in the project root and
 *       reports tracked ∩ should-be-ignored as `violations`. Exits 2 on any
 *       violation, 0 otherwise — matches the existing state-artifacts
 *       secret-found exit-code convention. With --scrub, additionally emits
 *       a `scrub` block with a filter-repo command + checklist + warning.
 *       NEVER shells out to `git filter-repo`. Printing only.
 *
 *   apt-tools audit visibility-signals <project-dir>
 *       Thin JSON wrapper consumed by /apt:setup. Surfaces publicRemote,
 *       existingGitignore (exists, hasAperantEntries, allowsAny,
 *       bypassedByShare), teamEnabled (from team.json), digestsEnabled (from
 *       config.digests.enabled), and currentPolicy (from config). The LLM
 *       reads this JSON and picks at most 3 questions for the user.
 *
 *   apt-tools audit pending-artifacts <project-dir> [--stage] [--fail-on-dirty]
 *       (C56 B2) Runs `git status --porcelain -- .aperant/`, classifies each
 *       dirty path via classifyArtifactPath, and buckets into committable /
 *       ephemeral / unknown. With --stage, calls selfStageCommittableWrites
 *       for the committable bucket. With --fail-on-dirty, exits 2 when the
 *       committable bucket is non-empty after staging (CI-friendly gate).
 *       Default: report-only, exits 0 regardless.
 *
 *   apt-tools audit branch-current <project-dir> [--base <ref>]
 *       Pre-PR rebase gate envelope for apt:ship §0.7. Pure read-only:
 *       `git fetch origin <base>`, captures origin/<base> tip SHA, counts
 *       ahead/behind, and (when diverged) probes mergeability with
 *       `git merge-tree --write-tree`. Returns {ahead, behind,
 *       remote_tip_sha, mergeable, conflicts_preview?}. Never mutates
 *       HEAD, the index, the worktree, or any remote ref.
 *
 * Single source of truth: buildPolicyIgnoreMatcher in share-policy.mjs is
 * the ONLY matcher anywhere in the CLI. If you add a new taxonomy class or
 * toggle, update share-policy and this command follows automatically.
 *
 * Spec cross-refs: spec.md §7 (visibility-signals), §8 (privacy), §B2
 * (pending-artifacts).
 *
 * @internal
 */
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { classifyArtifactPath } from '../artifacts/classification.mjs';
import { selfStageCommittableWrites } from '../artifacts/self-stage.mjs';
import { buildPolicyIgnoreMatcher, resolveSharePolicy } from '../config/share-policy.mjs';
import { detectPublicRemote } from '../git/remote.mjs';
import { err, exitWith, ok } from '../util/result.mjs';
import { auditBranchCurrent } from './audit-branch-current.mjs';
/**
 * Dispatch an `audit <subcommand>` call.
 *
 * @param {string} subcommand
 * @param {string} projectDir
 * @param {string[]} [extraArgs]
 *
 * @internal
 */
export function cmdAudit(subcommand, projectDir, extraArgs = []) {
    if (!subcommand) {
        return err('Usage: apt-tools audit <privacy|visibility-signals|pending-artifacts|branch-current> <project-dir> [flags]');
    }
    if (!projectDir) {
        return err(`Usage: apt-tools audit ${subcommand} <project-dir> [flags]`);
    }
    const targetDir = resolve(projectDir);
    switch (subcommand) {
        case 'privacy':
            return auditPrivacy(targetDir, extraArgs);
        case 'visibility-signals':
            return auditVisibilitySignals(targetDir);
        case 'pending-artifacts':
            return auditPendingArtifacts(targetDir, extraArgs);
        case 'branch-current':
            return auditBranchCurrent(targetDir, extraArgs);
        default:
            return err(`Unknown audit subcommand: ${subcommand}. Expected 'privacy', 'visibility-signals', 'pending-artifacts', or 'branch-current'.`);
    }
}
function loadConfig(targetDir) {
    const configPath = join(targetDir, '.aperant', 'config.json');
    if (!existsSync(configPath))
        return {};
    try {
        return JSON.parse(readFileSync(configPath, 'utf-8'));
    }
    catch {
        return {};
    }
}
function listTrackedAperantFiles(targetDir) {
    try {
        const out = execFileSync('git', ['ls-files', '-z', '--', '.aperant/'], {
            cwd: targetDir,
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'ignore'],
        });
        return out.split('\0').filter((entry) => entry.length > 0);
    }
    catch {
        return [];
    }
}
function auditPrivacy(targetDir, extraArgs) {
    const scrub = extraArgs.includes('--scrub');
    const config = loadConfig(targetDir);
    const policy = resolveSharePolicy(config);
    const matcher = buildPolicyIgnoreMatcher(policy);
    const tracked = listTrackedAperantFiles(targetDir);
    const violations = [];
    for (const file of tracked) {
        const verdict = matcher(file);
        if (verdict.ignored) {
            violations.push({
                path: file,
                reason: verdict.reason ?? 'policy-ignored',
                class: verdict.class ?? 'unknown',
            });
        }
    }
    const status = violations.length === 0 ? 'ok' : 'violations-found';
    const untrackCommand = violations.length > 0
        ? `git rm --cached -- ${violations.map((v) => JSON.stringify(v.path)).join(' ')}`
        : null;
    const payload = {
        status,
        command: 'audit-privacy',
        projectDir: targetDir,
        policy,
        violations,
        untrackCommand,
    };
    if (scrub && violations.length > 0) {
        const paths = violations.map((v) => v.path);
        payload.scrub = {
            command: `git filter-repo --invert-paths ${paths.map((p) => `--path ${JSON.stringify(p)}`).join(' ')}`,
            checklist: [
                'Back up the repo: cp -r . ../repo-backup',
                'Confirm remote + branch: git remote -v; git branch --show-current',
                'Install filter-repo if missing: brew install git-filter-repo',
                'Run the command above (it rewrites history — all collaborators must re-clone)',
                'Force-push the rewritten history: git push --force-with-lease origin <branch>',
                'Notify collaborators to re-clone',
            ],
            warning: 'Destructive. Rewrites git history and invalidates every outstanding clone. NEVER runs automatically.',
        };
    }
    else if (scrub) {
        // --scrub with no violations — emit an empty scrub block so callers can
        // rely on the key's presence.
        payload.scrub = null;
    }
    return violations.length > 0 ? exitWith(payload, 2) : ok(payload);
}
function parseGitignoreAllowRules(gitignorePath) {
    if (!existsSync(gitignorePath)) {
        return { exists: false, hasAperantEntries: false, allowsAny: [] };
    }
    const text = readFileSync(gitignorePath, 'utf-8');
    const lines = text.split('\n');
    const allowsAny = [];
    for (const raw of lines) {
        const line = raw.trim();
        if (!line || line.startsWith('#'))
            continue;
        if (line.startsWith('!')) {
            // Strip inline comments (e.g. `!team.json # class: shared-source`).
            const cleaned = line.replace(/\s+#.*$/, '').trim();
            allowsAny.push(cleaned);
        }
    }
    return {
        exists: true,
        hasAperantEntries: lines.some((l) => l.trim().length > 0 && !l.trim().startsWith('#')),
        allowsAny,
    };
}
function auditVisibilitySignals(targetDir) {
    const config = loadConfig(targetDir);
    const policy = resolveSharePolicy(config);
    const publicRemote = detectPublicRemote(targetDir);
    const gitignorePath = join(targetDir, '.aperant', '.gitignore');
    const existingGitignore = parseGitignoreAllowRules(gitignorePath);
    // bypassedByShare: allow rules in the gitignore that the current policy
    // does NOT authorize. Signals a hand-edited gitignore drifting from
    // config — /apt:setup can flag these to the user.
    const bypassedByShare = [];
    for (const rule of existingGitignore.allowsAny) {
        const probe = probeAllowRule(rule, policy);
        if (probe && !probe.authorized) {
            bypassedByShare.push({ rule, reason: probe.reason });
        }
    }
    // teamEnabled: read .aperant/team.json → `.enabled`
    let teamEnabled = false;
    const teamPath = join(targetDir, '.aperant', 'team.json');
    if (existsSync(teamPath)) {
        try {
            const team = JSON.parse(readFileSync(teamPath, 'utf-8'));
            teamEnabled = team?.enabled === true;
        }
        catch {
            teamEnabled = false;
        }
    }
    const digestsEnabled = config?.digests?.enabled === true;
    // Preserve the raw share block if present so the caller can distinguish
    // `null` (inherit) from `false` (explicit deny). The resolved policy
    // already collapses that, but the signal surface needs both.
    const currentPolicy = {
        visibility: config?.share?.visibility ?? 'solo',
        share: {
            roadmap: policy.roadmap,
            tasks: policy.tasks,
            team: { roster: policy.team.roster },
            decisions: policy.decisions,
            digests: config?.share?.digests === undefined || config?.share?.digests === null
                ? null
                : config.share.digests,
        },
    };
    return ok({
        status: 'ok',
        command: 'audit-visibility-signals',
        projectDir: targetDir,
        publicRemote,
        existingGitignore: {
            exists: existingGitignore.exists,
            hasAperantEntries: existingGitignore.hasAperantEntries,
            allowsAny: existingGitignore.allowsAny,
            bypassedByShare,
        },
        teamEnabled,
        digestsEnabled,
        currentPolicy,
    });
}
/**
 * Map a raw `!…` gitignore rule (e.g. `!team.json`, `!roadmap/**`) to the
 * toggle it implies and check whether the policy authorizes it. Returns null
 * if we don't recognise the rule (durable-source allowlists like
 * `!features/` always legitimate).
 */
function probeAllowRule(rule, policy) {
    // Strip the leading '!' for classification.
    const pattern = rule.replace(/^!/, '').trim();
    // Durable-source rules — always authorized.
    if (pattern === '.gitignore' ||
        pattern === 'config.json' ||
        pattern === 'axes.json' ||
        pattern.startsWith('features/') ||
        pattern.startsWith('prompts/') ||
        pattern.startsWith('templates/') ||
        pattern.startsWith('workflows/') ||
        pattern.startsWith('context/installed/') ||
        pattern.startsWith('standards/')) {
        return { authorized: true, reason: null };
    }
    // Shared-source.
    if (pattern === 'team.json') {
        return policy.team.roster
            ? { authorized: true, reason: null }
            : { authorized: false, reason: 'share.team.roster=false' };
    }
    if (pattern === 'roadmap/' || pattern.startsWith('roadmap/')) {
        return policy.roadmap
            ? { authorized: true, reason: null }
            : { authorized: false, reason: 'share.roadmap=false' };
    }
    if (pattern === 'decisions/' || /^decisions\/.+\.md$/.test(pattern)) {
        return policy.decisions
            ? { authorized: true, reason: null }
            : { authorized: false, reason: 'share.decisions=false' };
    }
    if (pattern === 'digests/' || /^digests\/.+\.md$/.test(pattern)) {
        return policy.digests
            ? { authorized: true, reason: null }
            : { authorized: false, reason: 'share.digests=false' };
    }
    if (pattern === 'tasks/' || pattern.startsWith('tasks/')) {
        return policy.tasks
            ? { authorized: true, reason: null }
            : { authorized: false, reason: 'share.tasks=false' };
    }
    // Unknown allow rule — not our business to flag, return null.
    return null;
}
/**
 * Parse `git status --porcelain -- .aperant/` output into a list of paths.
 *
 * Porcelain v1 line shape: `XY path` or `XY orig -> new` (rename). Untracked
 * is `?? path`. Deletion status (`D`) still surfaces the path — we include
 * it so the auditor can report on committable deletions too.
 *
 * @param {string} porcelain
 * @returns {string[]}
 * @internal
 */
function parsePorcelainPaths(porcelain) {
    const out = [];
    for (const rawLine of porcelain.split('\n')) {
        if (!rawLine)
            continue;
        // Porcelain v1 is fixed-width: 2 status chars + 1 space + path.
        const body = rawLine.slice(3);
        const arrowIdx = body.indexOf(' -> ');
        const raw = arrowIdx >= 0 ? body.slice(arrowIdx + 4) : body;
        const cleaned = raw.replace(/^"(.*)"$/, '$1').trim();
        if (cleaned)
            out.push(cleaned);
    }
    return out;
}
/**
 * C56 B2 — drift auditor. Buckets dirty .aperant/ paths into committable /
 * ephemeral / unknown. Optionally self-stages the committable bucket.
 *
 * @param {string} targetDir
 * @param {string[]} extraArgs
 * @internal
 */
function auditPendingArtifacts(targetDir, extraArgs) {
    const stage = extraArgs.includes('--stage');
    const failOnDirty = extraArgs.includes('--fail-on-dirty');
    let porcelain = '';
    try {
        // --untracked-files=all is required so git expands untracked directories
        // (e.g. `.aperant/`) into individual path entries instead of collapsing
        // to a single `?? .aperant/` line. CLAUDE.md's general warning about
        // `-uall` is about memory on repo-root scans; scoped to `.aperant/` the
        // path count is bounded and safe.
        porcelain = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '--', '.aperant/'], {
            cwd: targetDir,
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'pipe'],
        });
    }
    catch (e) {
        // Not a git repo, or .aperant/ missing. Emit an empty envelope.
        return ok({
            status: 'ok',
            command: 'audit-pending-artifacts',
            projectDir: targetDir,
            committable: [],
            ephemeral: [],
            unknown: [],
            staged: [],
            warnings: [`git status failed: ${e?.message ?? String(e)}`],
        });
    }
    const dirtyPaths = parsePorcelainPaths(porcelain);
    const committable = [];
    const ephemeral = [];
    const unknown = [];
    for (const path of dirtyPaths) {
        const cls = classifyArtifactPath(path);
        if (cls === 'committable')
            committable.push(path);
        else if (cls === 'ephemeral')
            ephemeral.push(path);
        else
            unknown.push(path);
    }
    const warnings = [];
    let staged = [];
    if (stage && committable.length > 0) {
        // No task-context allowlist is inferrable at this layer — audit runs
        // independent of a specific task. Pass `null` outputs so the helper
        // treats the call as unrestricted (the classification contract is the
        // only guard). Callers in B3 (executor subtask commit hook) / B2
        // (apt:ship pre-PR gate) can wrap this with a tighter allowlist.
        const res = selfStageCommittableWrites(targetDir, committable, { outputs: null });
        staged = res.staged;
        for (const w of res.warnings)
            warnings.push(w);
        for (const s of res.skipped) {
            warnings.push(`self-stage skipped ${s.path}: ${s.reason}`);
        }
    }
    if (unknown.length > 0) {
        warnings.push(`classifier-gap: ${unknown.length} path(s) under .aperant/ have no classification — add them to classification.mjs or remove them: ${unknown.slice(0, 5).join(', ')}`);
    }
    // Recompute committable-remaining for the fail-on-dirty check — paths we
    // couldn't stage stay in the committable list.
    const stagedSet = new Set(staged);
    const committableRemaining = stage ? committable.filter((p) => !stagedSet.has(p)) : committable;
    const payload = {
        status: 'ok',
        command: 'audit-pending-artifacts',
        projectDir: targetDir,
        committable,
        ephemeral,
        unknown,
        staged,
        warnings,
    };
    if (failOnDirty && (committableRemaining.length > 0 || unknown.length > 0)) {
        return exitWith(payload, 2);
    }
    return ok(payload);
}
//# sourceMappingURL=audit.mjs.map