"use strict";
/**
 * On-disk allowlist file — `.dxkit/allowlist.json` + optional sidecar
 * `.dxkit/allowlist-reasons.local.json`.
 *
 * The allowlist file is the durable contract for per-finding
 * suppressions: customer has reviewed this fingerprint, categorized
 * the reason, and accepts that the guardrail should let the finding
 * pass on future runs.
 *
 * Identity is the 16-char hex fingerprint from
 * `src/analyzers/tools/fingerprint.ts` (CLAUDE.md Rule 9). An entry
 * matches a finding when their fingerprint strings are byte-equal.
 *
 * # Two modes, one schema
 *
 * The mode is recorded in `.dxkit/policy.json` (out of scope for this
 * module — consumers pass it in via `AllowlistMode`). Both modes use
 * the same `AllowlistFile` shape on disk; sanitized mode just drops
 * the human-readable fields and pushes them to a sidecar:
 *
 *   `'full'` — every field present on the entry. Default for
 *   private repos. The committed file carries the full audit trail.
 *
 *   `'sanitized'` — entries carry `fingerprint + kind + category +
 *   addedAt + expiresAt + acknowledgedSeverity` only. The
 *   `reason` + `addedBy` fields live in the gitignored sidecar
 *   `.dxkit/allowlist-reasons.local.json`, keyed by fingerprint.
 *   Default for public repos. Loaders merge the sidecar back when
 *   present; readers tolerate its absence (no reason field, but
 *   the suppression still applies because matching is by
 *   fingerprint).
 *
 * # Validation surface
 *
 * `validateAllowlistFile` enforces every Sprint-0-locked rule:
 *   - `reason` is required (full mode) / required in sidecar
 *     (sanitized mode)
 *   - `category` is one of the five canonical values
 *   - `category` is valid for the entry's `kind`
 *   - `requiresExpiry(category)` ⇒ `expiresAt` is present + parseable
 *   - `acknowledgedSeverity` is required when `category` is
 *     `accepted-risk` AND `severity` is `high`/`critical`
 */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.ALL_MODES = exports.ALLOWLIST_REASONS_FILENAME = exports.ALLOWLIST_FILENAME = exports.ALLOWLIST_DIR = exports.ALLOWLIST_REASONS_SCHEMA_VERSION = exports.ALLOWLIST_SCHEMA_VERSION = void 0;
exports.pathForAllowlist = pathForAllowlist;
exports.pathForAllowlistReasons = pathForAllowlistReasons;
exports.emptyAllowlistFile = emptyAllowlistFile;
exports.loadAllowlist = loadAllowlist;
exports.loadAllowlistReasons = loadAllowlistReasons;
exports.saveAllowlist = saveAllowlist;
exports.findEntry = findEntry;
exports.addEntry = addEntry;
exports.removeEntry = removeEntry;
exports.matchesFinding = matchesFinding;
exports.isEntryActive = isEntryActive;
exports.daysUntilExpiry = daysUntilExpiry;
exports.auditAllowlist = auditAllowlist;
exports.pruneExpired = pruneExpired;
exports.validateAllowlistFile = validateAllowlistFile;
exports.validateAllowlistEntry = validateAllowlistEntry;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const categories_1 = require("./categories");
exports.ALLOWLIST_SCHEMA_VERSION = 'dxkit-allowlist/v1';
exports.ALLOWLIST_REASONS_SCHEMA_VERSION = 'dxkit-allowlist-reasons/v1';
exports.ALLOWLIST_DIR = '.dxkit';
exports.ALLOWLIST_FILENAME = 'allowlist.json';
exports.ALLOWLIST_REASONS_FILENAME = 'allowlist-reasons.local.json';
/**
 * Single source of truth for mode values. The `AllowlistMode` union
 * is derived from this array via `(typeof ...)[number]`, so adding
 * a new mode means appending one string here — the runtime checks
 * below pick it up via `ALL_MODES.includes(...)` without any
 * literal-value drift between type and runtime.
 */
exports.ALL_MODES = ['full', 'sanitized'];
function pathForAllowlist(cwd) {
    return path.join(cwd, exports.ALLOWLIST_DIR, exports.ALLOWLIST_FILENAME);
}
function pathForAllowlistReasons(cwd) {
    return path.join(cwd, exports.ALLOWLIST_DIR, exports.ALLOWLIST_REASONS_FILENAME);
}
/**
 * Create an empty allowlist file in the requested mode. Useful for
 * the `vyuh-dxkit allowlist add` CLI path when no file exists yet.
 */
function emptyAllowlistFile(mode = 'full') {
    return { schemaVersion: exports.ALLOWLIST_SCHEMA_VERSION, mode, entries: [] };
}
/**
 * Read the on-disk allowlist + (when present) merge the sidecar
 * reasons. Returns `null` when the main file doesn't exist —
 * callers treat that as "no allowlist configured."
 *
 * Throws on:
 *   - Malformed JSON in either file
 *   - Unrecognized `schemaVersion`
 *   - Root is not an object
 *
 * The sidecar's absence is NOT an error: customers in sanitized
 * mode may have a committed allowlist on disk without the sidecar
 * cloned (CI checkout, fresh teammate clone). The fingerprint-only
 * file is still functional as a suppression list.
 */
function loadAllowlist(cwd) {
    const filePath = pathForAllowlist(cwd);
    if (!fs.existsSync(filePath))
        return null;
    const raw = fs.readFileSync(filePath, 'utf8');
    let parsed;
    try {
        parsed = JSON.parse(raw);
    }
    catch (err) {
        throw new Error(`allowlist file is not valid JSON: ${filePath} (${err.message})`);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
        throw new Error(`allowlist file root is not an object: ${filePath}`);
    }
    const obj = parsed;
    if (obj.schemaVersion !== exports.ALLOWLIST_SCHEMA_VERSION) {
        throw new Error(`allowlist file schemaVersion is ${JSON.stringify(obj.schemaVersion)}; ` +
            `this dxkit understands ${JSON.stringify(exports.ALLOWLIST_SCHEMA_VERSION)} only ` +
            `(${filePath})`);
    }
    if (!isAllowlistMode(obj.mode)) {
        throw new Error(`allowlist file mode is ${JSON.stringify(obj.mode)}; ` +
            `expected one of ${JSON.stringify(exports.ALL_MODES)} (${filePath})`);
    }
    if (!Array.isArray(obj.entries)) {
        throw new Error(`allowlist file entries is not an array (${filePath})`);
    }
    const base = parsed;
    if (base.mode === 'full')
        return base;
    // sanitized mode → merge sidecar if present
    const sidecar = loadAllowlistReasons(cwd);
    if (!sidecar)
        return base;
    return mergeReasons(base, sidecar);
}
/**
 * Load the gitignored reasons sidecar. Returns `null` when missing.
 * Throws on malformed JSON or wrong schemaVersion.
 */
function loadAllowlistReasons(cwd) {
    const filePath = pathForAllowlistReasons(cwd);
    if (!fs.existsSync(filePath))
        return null;
    const raw = fs.readFileSync(filePath, 'utf8');
    let parsed;
    try {
        parsed = JSON.parse(raw);
    }
    catch (err) {
        throw new Error(`allowlist reasons sidecar is not valid JSON: ${filePath} (${err.message})`);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
        throw new Error(`allowlist reasons sidecar root is not an object: ${filePath}`);
    }
    const obj = parsed;
    if (obj.schemaVersion !== exports.ALLOWLIST_REASONS_SCHEMA_VERSION) {
        throw new Error(`allowlist reasons sidecar schemaVersion is ${JSON.stringify(obj.schemaVersion)}; ` +
            `expected ${JSON.stringify(exports.ALLOWLIST_REASONS_SCHEMA_VERSION)} (${filePath})`);
    }
    if (!obj.reasons || typeof obj.reasons !== 'object' || Array.isArray(obj.reasons)) {
        throw new Error(`allowlist reasons sidecar 'reasons' is not an object (${filePath})`);
    }
    return parsed;
}
/**
 * Persist the allowlist to disk. Writes the sidecar separately in
 * sanitized mode: the committed file gets the structural fields;
 * the sidecar gets `reason` + `addedBy`.
 *
 * Validation runs before writing — invalid input throws and the
 * file isn't touched. Use `validateAllowlistFile` directly when
 * you want non-throwing error reporting.
 */
function saveAllowlist(cwd, file) {
    const errors = validateAllowlistFile(file);
    if (errors.length > 0) {
        throw new Error(`allowlist file failed validation:\n` +
            errors.map((e) => `  - ${e.field}: ${e.message}`).join('\n'));
    }
    if (file.mode === 'full') {
        writeJsonPretty(pathForAllowlist(cwd), file);
        return;
    }
    // sanitized mode: split entries → main + sidecar
    const sanitizedEntries = [];
    const reasons = {};
    for (const entry of file.entries) {
        sanitizedEntries.push(sanitizedEntry(entry));
        if (entry.reason !== undefined && entry.addedBy !== undefined) {
            reasons[entry.fingerprint] = { reason: entry.reason, addedBy: entry.addedBy };
        }
    }
    const sanitizedFile = {
        schemaVersion: file.schemaVersion,
        mode: 'sanitized',
        entries: sanitizedEntries,
    };
    const sidecar = {
        schemaVersion: exports.ALLOWLIST_REASONS_SCHEMA_VERSION,
        reasons,
    };
    writeJsonPretty(pathForAllowlist(cwd), sanitizedFile);
    writeJsonPretty(pathForAllowlistReasons(cwd), sidecar);
}
/** Find an entry by fingerprint. */
function findEntry(file, fingerprint) {
    return file.entries.find((e) => e.fingerprint === fingerprint);
}
/**
 * Add an entry. Returns a NEW `AllowlistFile` (immutable update).
 * Throws if `entry.fingerprint` already exists.
 */
function addEntry(file, entry) {
    if (findEntry(file, entry.fingerprint)) {
        throw new Error(`allowlist already contains entry for fingerprint ${entry.fingerprint}; ` +
            `use removeEntry first or update in place`);
    }
    return { ...file, entries: [...file.entries, entry] };
}
/**
 * Remove an entry by fingerprint. Returns a NEW `AllowlistFile`.
 * Silently no-ops when the fingerprint isn't present (CLI surfaces
 * the "not found" case through a separate read step).
 */
function removeEntry(file, fingerprint) {
    return { ...file, entries: file.entries.filter((e) => e.fingerprint !== fingerprint) };
}
/**
 * Whether the allowlist suppresses a given finding. Pure
 * fingerprint match. Expiry handling is layered on top by
 * `isEntryActive` (kept separate so the guardrail can distinguish
 * "matched but expired" from "no match").
 */
function matchesFinding(file, fingerprint) {
    return findEntry(file, fingerprint) !== undefined;
}
/**
 * Whether an entry is currently active (within its expiry window).
 * Entries without `expiresAt` are always active. Entries past their
 * expiry are inactive — guardrail treats the underlying finding as
 * un-allowlisted and the next run flags the suppression as stale.
 */
function isEntryActive(entry, now = new Date()) {
    if (!entry.expiresAt)
        return true;
    const today = now.toISOString().slice(0, 10);
    return entry.expiresAt >= today;
}
/**
 * Days remaining until an entry expires, or `null` when it has no
 * expiry. Negative values mean already expired by that many days.
 */
function daysUntilExpiry(entry, now = new Date()) {
    if (!entry.expiresAt)
        return null;
    const expiry = new Date(entry.expiresAt + 'T00:00:00Z');
    const today = new Date(now.toISOString().slice(0, 10) + 'T00:00:00Z');
    const msPerDay = 24 * 60 * 60 * 1000;
    return Math.round((expiry.getTime() - today.getTime()) / msPerDay);
}
function auditAllowlist(file, options = {}) {
    const now = options.now ?? new Date();
    const horizon = options.soonToExpireDays ?? 14;
    const expired = [];
    const soonToExpire = [];
    const missingRationale = [];
    for (const entry of file.entries) {
        const days = daysUntilExpiry(entry, now);
        if (days !== null && days < 0) {
            expired.push(entry);
        }
        else if (days !== null && days <= horizon) {
            soonToExpire.push({ entry, daysRemaining: days });
        }
        if (!entry.reason || entry.reason.trim().length === 0) {
            // In sanitized mode the reason may legitimately live in the
            // gitignored sidecar; flag here so the caller can decide
            // whether to treat it as a real audit item or just an
            // "unavailable locally" notice.
            missingRationale.push(entry);
        }
    }
    return { expired, soonToExpire, missingRationale };
}
/**
 * Remove expired entries from the file. Returns a new file (immutable)
 * plus the list of removed entries so the CLI can render what changed.
 * Pure function — no I/O.
 */
function pruneExpired(file, now = new Date()) {
    const removed = [];
    const keptEntries = [];
    for (const entry of file.entries) {
        if (isEntryActive(entry, now)) {
            keptEntries.push(entry);
        }
        else {
            removed.push(entry);
        }
    }
    return { kept: { ...file, entries: keptEntries }, removed };
}
/**
 * Validate every entry in the file against the canonical taxonomy
 * + Sprint-0-locked rules. Pure function; returns an array of
 * `ValidationError` rather than throwing so callers can render
 * structured error messages.
 */
function validateAllowlistFile(file) {
    const errors = [];
    if (file.schemaVersion !== exports.ALLOWLIST_SCHEMA_VERSION) {
        errors.push({
            field: 'schemaVersion',
            message: `expected ${JSON.stringify(exports.ALLOWLIST_SCHEMA_VERSION)}, got ${JSON.stringify(file.schemaVersion)}`,
        });
    }
    if (!isAllowlistMode(file.mode)) {
        errors.push({
            field: 'mode',
            message: `expected one of ${JSON.stringify(exports.ALL_MODES)}, got ${JSON.stringify(file.mode)}`,
        });
    }
    const seen = new Set();
    for (const entry of file.entries) {
        if (seen.has(entry.fingerprint)) {
            errors.push({
                fingerprint: entry.fingerprint,
                field: 'fingerprint',
                message: `duplicate fingerprint`,
            });
        }
        seen.add(entry.fingerprint);
        for (const err of validateAllowlistEntry(entry, file.mode))
            errors.push(err);
    }
    return errors;
}
/**
 * Validate a single entry. Exposed independently so the CLI's
 * `allowlist add` can pre-flight before mutating the file.
 */
function validateAllowlistEntry(entry, mode) {
    const errors = [];
    const fp = entry.fingerprint;
    if (!fp || typeof fp !== 'string' || !/^[0-9a-f]{16}$/.test(fp)) {
        errors.push({
            fingerprint: fp,
            field: 'fingerprint',
            message: 'must be a 16-char lowercase hex string',
        });
    }
    if (!categories_1.ALL_CATEGORIES.includes(entry.category)) {
        errors.push({
            fingerprint: fp,
            field: 'category',
            message: `must be one of ${JSON.stringify(categories_1.ALL_CATEGORIES)}; got ${JSON.stringify(entry.category)}`,
        });
    }
    if (!(0, categories_1.isCategoryValidForKind)(entry.kind, entry.category)) {
        errors.push({
            fingerprint: fp,
            field: 'category',
            message: `category ${JSON.stringify(entry.category)} does not apply to kind ${JSON.stringify(entry.kind)}`,
        });
    }
    // reason: required in full mode; sidecar-owned in sanitized mode (so
    // the entry on disk legitimately omits it). The CLI write path emits
    // a separate validation pass for the sidecar.
    if (mode === 'full') {
        if (entry.reason === undefined || entry.reason === null) {
            errors.push({ fingerprint: fp, field: 'reason', message: 'required in full mode' });
        }
        else if (typeof entry.reason !== 'string' || entry.reason.trim().length === 0) {
            errors.push({
                fingerprint: fp,
                field: 'reason',
                message: 'must be a non-empty string',
            });
        }
        if (entry.addedBy === undefined || entry.addedBy === null) {
            errors.push({ fingerprint: fp, field: 'addedBy', message: 'required in full mode' });
        }
        else if (typeof entry.addedBy !== 'string' || entry.addedBy.trim().length === 0) {
            errors.push({
                fingerprint: fp,
                field: 'addedBy',
                message: 'must be a non-empty string',
            });
        }
    }
    if (!entry.addedAt || !/^\d{4}-\d{2}-\d{2}$/.test(entry.addedAt)) {
        errors.push({
            fingerprint: fp,
            field: 'addedAt',
            message: 'must be ISO date YYYY-MM-DD',
        });
    }
    if ((0, categories_1.requiresExpiry)(entry.category)) {
        if (!entry.expiresAt) {
            errors.push({
                fingerprint: fp,
                field: 'expiresAt',
                message: `required for category ${JSON.stringify(entry.category)}`,
            });
        }
    }
    if (entry.expiresAt !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(entry.expiresAt)) {
        errors.push({
            fingerprint: fp,
            field: 'expiresAt',
            message: 'must be ISO date YYYY-MM-DD when present',
        });
    }
    // Note on acknowledgedSeverity: the rule "accepted-risk on a
    // high/critical finding requires acknowledgedSeverity" can't be
    // enforced from inside this validator — the finding's severity
    // doesn't live on the on-disk entry. The CLI's `allowlist add`
    // path enforces it at write time when the finding is in scope.
    return errors;
}
// ─── Internals ───────────────────────────────────────────────────────────
function isAllowlistMode(value) {
    return typeof value === 'string' && exports.ALL_MODES.includes(value);
}
function writeJsonPretty(filePath, value) {
    fs.mkdirSync(path.dirname(filePath), { recursive: true });
    fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
}
function sanitizedEntry(entry) {
    // Strip `reason` + `addedBy`; everything else stays on disk in the
    // committed file. The fingerprint contract preserves matching.
    // We intentionally rebuild via property assignment rather than
    // destructuring so the optional-undefined fields don't survive
    // serialization as explicit `null`s.
    const out = {
        fingerprint: entry.fingerprint,
        kind: entry.kind,
        category: entry.category,
        addedAt: entry.addedAt,
        ...(entry.expiresAt !== undefined ? { expiresAt: entry.expiresAt } : {}),
        ...(entry.acknowledgedSeverity !== undefined
            ? { acknowledgedSeverity: entry.acknowledgedSeverity }
            : {}),
    };
    return out;
}
function mergeReasons(file, sidecar) {
    const merged = file.entries.map((entry) => {
        const r = sidecar.reasons[entry.fingerprint];
        if (!r)
            return entry;
        return { ...entry, reason: r.reason, addedBy: r.addedBy };
    });
    return { ...file, entries: merged };
}
//# sourceMappingURL=file.js.map