/**
 * agent-dir-scanner.ts
 *
 * Scan a directory for AI agent source files and classify them by provider
 * and framework. Pure function — no disk writes, only reads + directory walk.
 *
 * F-NEW-PRODUCT-AGENT-DIR-WRAPPER slice 1
 * @connexum/ai-governance
 */
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { PYTHON_SIGNATURES, PYTHON_FRAMEWORK_SIGNATURES, TYPESCRIPT_SIGNATURES, TYPESCRIPT_FRAMEWORK_SIGNATURES, } from './agent-signatures.js';
import { ScanBudget } from './scan-limits.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SKIPPED_DIRS = new Set([
    'node_modules',
    '__pycache__',
    '.git',
    'dist',
    'build',
    '.venv',
    'venv',
]);
const PYTHON_EXTS = new Set(['.py']);
const TS_EXTS = new Set(['.ts', '.tsx', '.js', '.mjs', '.jsx']);
// Lines longer than this are not run through the signature regexes. A real
// import / provider-call line is well under this; lines this long come from
// minified bundles or generated blobs. The signature regexes use only single
// quantifiers (no nested quantifiers → no catastrophic backtracking), but the
// `.*`-bearing ones are still O(line length), so without this a single
// multi-MB line would cost O(line) × (#signatures) per file. Bounding the
// per-line input makes the analysis cost independent of any one line's size —
// defence-in-depth that does not rely on every regex staying ReDoS-safe.
// Override via CXNI_SCAN_MAX_LINE_CHARS for unusual codebases.
const MAX_ANALYSED_LINE_CHARS = (() => {
    const raw = process.env.CXNI_SCAN_MAX_LINE_CHARS;
    const n = raw ? Number.parseInt(raw, 10) : NaN;
    return Number.isFinite(n) && n > 0 ? n : 4000;
})();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function languageOf(ext) {
    if (ext === '.py')
        return 'python';
    if (ext === '.ts' || ext === '.tsx')
        return 'typescript';
    if (ext === '.js' || ext === '.mjs' || ext === '.jsx')
        return 'javascript';
    return null;
}
/**
 * Strip triple-quoted Python blocks (both """ and ''') from a line array so
 * that signature matches inside docstrings are not counted. Returns a copy of
 * lines where the interior of any triple-quoted block is replaced with blank
 * strings. Lines that START a triple-quote block and immediately END it on the
 * same line (e.g. """value""") are also neutralised.
 */
function stripPythonTripleQuotes(lines) {
    const result = [];
    let inTriple = false;
    let tripleChar = '';
    for (const line of lines) {
        if (!inTriple) {
            // Look for the start of a triple-quote block
            const dq = line.indexOf('"""');
            const sq = line.indexOf("'''");
            const startIdx = dq === -1 && sq === -1
                ? -1
                : dq === -1 ? sq : sq === -1 ? dq : Math.min(dq, sq);
            if (startIdx !== -1) {
                const delim = line[startIdx] === '"' ? '"""' : "'''";
                const afterStart = line.indexOf(delim, startIdx + 3);
                if (afterStart !== -1) {
                    // Opened and closed on the same line — blank the interior
                    result.push(line.slice(0, startIdx));
                }
                else {
                    // Started but not closed on this line — blank rest of line and enter block
                    result.push(line.slice(0, startIdx));
                    inTriple = true;
                    tripleChar = delim;
                }
            }
            else {
                result.push(line);
            }
        }
        else {
            // Inside a triple-quoted block — push blank until closing delimiter
            const closeIdx = line.indexOf(tripleChar);
            if (closeIdx !== -1) {
                result.push(''); // blank; closing delimiter line consumed
                inTriple = false;
                tripleChar = '';
            }
            else {
                result.push('');
            }
        }
    }
    return result;
}
/**
 * Strip single-line comments before regex matching.
 * Python: # ...
 * JS/TS: // ...
 */
function stripLineComment(line, lang) {
    if (lang === 'python') {
        const idx = line.indexOf('#');
        if (idx !== -1)
            return line.slice(0, idx);
    }
    else {
        const idx = line.indexOf('//');
        if (idx !== -1)
            return line.slice(0, idx);
    }
    return line;
}
function stableId(filePath, pattern) {
    const hash = crypto.createHash('sha256').update(filePath + '|' + pattern).digest('hex');
    return 'auto-' + hash.slice(0, 12);
}
function analyseFile(absolutePath, relPath, lang) {
    let raw;
    try {
        raw = fs.readFileSync(absolutePath, 'utf-8');
    }
    catch {
        return null;
    }
    const rawLines = raw.split('\n');
    // For Python: strip triple-quoted blocks first to avoid docstring false-positives
    const lines = lang === 'python' ? stripPythonTripleQuotes(rawLines) : rawLines;
    const lineRanges = [];
    let provider = null;
    let framework = null;
    let firstPattern = '';
    const providerSigs = lang === 'python' ? PYTHON_SIGNATURES : TYPESCRIPT_SIGNATURES;
    const frameworkSigs = lang === 'python'
        ? PYTHON_FRAMEWORK_SIGNATURES
        : TYPESCRIPT_FRAMEWORK_SIGNATURES;
    for (let i = 0; i < lines.length; i++) {
        // Bound the input handed to every signature regex (see
        // MAX_ANALYSED_LINE_CHARS): a multi-MB minified line is skipped, not
        // scanned. Import statements we care about are always short.
        if (lines[i].length > MAX_ANALYSED_LINE_CHARS)
            continue;
        const stripped = stripLineComment(lines[i], lang);
        // Check framework signatures first (they may also carry provider info)
        for (const sig of frameworkSigs) {
            if (sig.regex.test(stripped)) {
                if (!framework) {
                    framework = sig.framework;
                    if (sig.provider && !provider)
                        provider = sig.provider;
                    if (!firstPattern)
                        firstPattern = sig.regex.source;
                }
                lineRanges.push({ start: i + 1, end: i + 1, match: rawLines[i].trim() });
                break;
            }
        }
        // Check provider signatures
        for (const sig of providerSigs) {
            if (sig.regex.test(stripped)) {
                if (!provider) {
                    provider = sig.provider;
                    if (!firstPattern)
                        firstPattern = sig.regex.source;
                }
                lineRanges.push({ start: i + 1, end: i + 1, match: rawLines[i].trim() });
                break;
            }
        }
    }
    if (lineRanges.length === 0)
        return null;
    // Deduplicate line ranges (a line may match both framework and provider sig)
    const seen = new Set();
    const dedupedRanges = [];
    for (const lr of lineRanges) {
        if (!seen.has(lr.start)) {
            seen.add(lr.start);
            dedupedRanges.push(lr);
        }
    }
    return { provider, framework, lineRanges: dedupedRanges, kind: 'import', firstPattern };
}
// ---------------------------------------------------------------------------
// Directory walker
// ---------------------------------------------------------------------------
/**
 * Map a free-form `language` string from a manifest.json to the scanner's
 * three-language enum. Defaults to 'python' (the most common agent runtime in
 * the fleet convention) when unrecognized — the manifest is authoritative, the
 * enum only drives wrap-shim selection.
 */
function manifestLanguage(raw) {
    const s = typeof raw === 'string' ? raw.toLowerCase() : '';
    if (s.includes('typescript') || s === 'ts')
        return 'typescript';
    if (s.includes('javascript') || s === 'js' || s === 'node')
        return 'javascript';
    return 'python';
}
/** Best-effort provider inference from a model identifier in a manifest. */
function providerFromModel(model) {
    const s = typeof model === 'string' ? model.toLowerCase() : '';
    if (!s)
        return null;
    if (s.includes('claude') || s.includes('anthropic'))
        return 'anthropic';
    if (s.startsWith('gpt') || s.includes('openai') || s.startsWith('o1') || s.startsWith('o3'))
        return 'openai';
    if (s.includes('gemini'))
        return 'google';
    if (s.includes('llama') || s.includes('mistral') || s.includes('deepseek'))
        return 'open-weights';
    return null;
}
/**
 * Parse a `manifest.json` as a declarative agent definition (the fleet
 * convention — each agent subdir carries one). Returns a DetectedAgent only
 * when the JSON has the agent shape (an `agent_name`/`name` plus at least one
 * of tools/framework/system_prompt), so we don't mistake an unrelated
 * `manifest.json` (web app manifest, npm artifact, etc.) for an agent.
 * NEVER throws (Invariant 10) — malformed JSON → null.
 */
function parseAgentManifestJson(relPath, text) {
    let obj;
    try {
        obj = JSON.parse(text);
    }
    catch {
        return null;
    }
    if (!obj || typeof obj !== 'object')
        return null;
    const name = (obj['agent_name'] ?? obj['name']);
    if (typeof name !== 'string' || name.trim().length === 0)
        return null;
    const hasAgentShape = 'tools' in obj || 'framework' in obj || 'system_prompt' in obj || 'model_identifier' in obj;
    if (!hasAgentShape)
        return null;
    // Capture declared tool names + MCP servers (safe metadata only — never the
    // system_prompt or any raw source, Invariant 10) so the platform's OWASP/ASI
    // analysis grades the real tool surface. Cap counts/length defensively.
    const toStringList = (v) => {
        if (!Array.isArray(v))
            return undefined;
        const out = v
            .map((x) => {
            if (typeof x === 'string')
                return x;
            if (x && typeof x === 'object' && typeof x['name'] === 'string') {
                return x['name'];
            }
            return null;
        })
            .filter((s) => typeof s === 'string' && s.trim().length > 0)
            .map((s) => s.trim().slice(0, 200))
            .slice(0, 200);
        return out.length > 0 ? out : undefined;
    };
    return {
        agentId: stableId(relPath, `manifest:${name}`),
        filePath: relPath,
        language: manifestLanguage(obj['language']),
        provider: providerFromModel(obj['model_identifier']),
        framework: typeof obj['framework'] === 'string' ? obj['framework'] : null,
        lineRanges: [],
        wrapMode: 'shim',
        packs: [],
        name: name.trim(),
        fromManifest: true,
        tools: toStringList(obj['tools']),
        mcpServers: toStringList(obj['mcp_servers'] ?? obj['mcpServers']),
    };
}
function walkDir(dirPath, baseDir, results, budget, depth) {
    // Resource caps (Invariants 2 + 10): stop descending once the file-count cap
    // or the wall-clock budget is hit, or when this directory is past the depth
    // cap. budget records which cap tripped so the scan can report it (no silent
    // truncation).
    if (!budget.canContinue() || !budget.withinDepth(depth))
        return;
    let entries;
    try {
        entries = fs.readdirSync(dirPath, { withFileTypes: true });
    }
    catch {
        return;
    }
    for (const entry of entries) {
        if (!budget.canContinue())
            return;
        // Never follow symlinks: a symlink cycle would otherwise be an unbounded
        // (infinite) walk. readdir Dirents reflect the link itself, so isDirectory/
        // isFile are already false for links, but skip explicitly so a future
        // refactor (e.g. recursive readdir) can't reintroduce the cycle.
        if (entry.isSymbolicLink())
            continue;
        if (entry.isDirectory()) {
            if (SKIPPED_DIRS.has(entry.name))
                continue;
            walkDir(path.join(dirPath, entry.name), baseDir, results, budget, depth + 1);
        }
        else if (entry.isFile()) {
            // Declarative agent definition (fleet convention): each agent subdir
            // carries a manifest.json. Recognize it as a first-class agent so a fleet
            // of N agents is detected as N — code-signature heuristics miss agents
            // written in Python or without a recognized SDK import.
            if (entry.name === 'manifest.json') {
                const absPath = path.join(dirPath, entry.name);
                let size;
                try {
                    size = fs.statSync(absPath).size;
                }
                catch {
                    continue;
                }
                if (!budget.allowFileSize(size))
                    continue;
                budget.countVisit();
                let text;
                try {
                    text = fs.readFileSync(absPath, 'utf8');
                }
                catch {
                    continue;
                }
                const relPath = path.relative(baseDir, absPath);
                const manifestAgent = parseAgentManifestJson(relPath, text);
                if (manifestAgent)
                    results.push(manifestAgent);
                continue;
            }
            const ext = path.extname(entry.name).toLowerCase();
            if (!PYTHON_EXTS.has(ext) && !TS_EXTS.has(ext))
                continue;
            const lang = languageOf(ext);
            if (!lang)
                continue;
            const absPath = path.join(dirPath, entry.name);
            // Per-file byte ceiling: stat before reading so an oversize/generated
            // file is never loaded into memory (also bounds the per-line regex pass).
            let size;
            try {
                size = fs.statSync(absPath).size;
            }
            catch {
                continue;
            }
            if (!budget.allowFileSize(size))
                continue;
            budget.countVisit();
            const relPath = path.relative(baseDir, absPath);
            const match = analyseFile(absPath, relPath, lang);
            if (!match)
                continue;
            const agentId = stableId(relPath, match.firstPattern);
            results.push({
                agentId,
                filePath: relPath,
                language: lang,
                provider: match.provider,
                framework: match.framework,
                lineRanges: match.lineRanges,
                wrapMode: 'shim',
                packs: [],
            });
        }
    }
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/**
 * Recursively scan `dirPath` for Python / TS / JS files that contain AI
 * provider or framework imports. Returns one DetectedAgent per file that
 * matches at least one signature.
 *
 * Pure function: no disk writes, no async I/O.
 *
 * Accepts an optional shared {@link ScanBudget} that enforces hard caps
 * (file count, recursion depth, per-file size, wall-clock time) so the walk
 * can never run away on a customer's machine. When omitted, a default budget
 * is created; callers that need the report (e.g. the CLI) pass their own.
 */
export function scanAgentDirectory(dirPath, budget = new ScanBudget()) {
    const results = [];
    walkDir(dirPath, dirPath, results, budget, 0);
    return dedupeByManifestDir(results);
}
/**
 * When a directory has an agent-shaped manifest.json, that manifest IS the
 * agent for that directory — drop any code-signature detections located in the
 * same directory (or below it) so a manifest agent isn't double-counted with
 * the .py/.ts file(s) that implement it. Code-signature agents in directories
 * with NO manifest are kept as-is.
 */
function dedupeByManifestDir(agents) {
    const manifestDirs = agents
        .filter((a) => a.fromManifest)
        .map((a) => {
        const d = path.dirname(a.filePath);
        return d === '.' ? '' : d;
    });
    if (manifestDirs.length === 0)
        return agents;
    const isUnderManifestDir = (filePath) => {
        const dir = path.dirname(filePath);
        const norm = dir === '.' ? '' : dir;
        // md === '' is a manifest at the scan root → it covers the whole tree.
        return manifestDirs.some((md) => md === '' || norm === md || norm.startsWith(md + path.sep));
    };
    return agents.filter((a) => {
        if (a.fromManifest)
            return true; // always keep manifest agents
        return !isUnderManifestDir(a.filePath); // drop code agents covered by a manifest
    });
}
//# sourceMappingURL=agent-dir-scanner.js.map