/**
 * scan-limits.test.ts — resource caps for customer-side `ai-governance scan`.
 *
 * Step 0 probe: scan walks + reads every source file in a customer repo with
 *   no bound. A pathological tree (millions of files / a multi-GB file) could
 *   spike CPU/memory on the customer's box (Invariants 2 + 10).
 * Step 0 evidence: agent-dir-scanner.ts walkDir/analyseFile and scan.ts walk
 *   had no file-count / depth / size / time cap before this change.
 * Step 0 customer-path: customer runs `ai-governance scan <repo>` →
 *   scanAgentDirectory walks the tree → unbounded read/analyse.
 */
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ScanBudget, defaultScanLimits } from './scan-limits.js';
import { scanAgentDirectory } from './agent-dir-scanner.js';
describe('ScanBudget', () => {
    it('stops continuing once the file-count cap is reached', () => {
        const b = new ScanBudget({ maxFiles: 3, maxDepth: 50, maxFileBytes: 1e9, timeBudgetMs: 1e9 });
        assert.equal(b.canContinue(), true);
        b.countVisit();
        b.countVisit();
        b.countVisit();
        assert.equal(b.canContinue(), false);
        assert.equal(b.report().truncatedByFileCap, true);
        assert.equal(b.report().capped, true);
    });
    it('expires when the wall-clock budget passes (no timers)', () => {
        const start = 1_000_000;
        const b = new ScanBudget({ maxFiles: 1e9, maxDepth: 50, maxFileBytes: 1e9, timeBudgetMs: 100 }, start);
        assert.equal(b.canContinue(start + 50), true);
        assert.equal(b.canContinue(start + 150), false);
        assert.equal(b.report().truncatedByTimeBudget, true);
    });
    it('rejects oversize files and records the skip', () => {
        const b = new ScanBudget({ maxFiles: 1e9, maxDepth: 50, maxFileBytes: 1024, timeBudgetMs: 1e9 });
        assert.equal(b.allowFileSize(500), true);
        assert.equal(b.allowFileSize(2048), false);
        assert.equal(b.report().filesSkippedOversize, 1);
        assert.equal(b.report().capped, true);
    });
    it('caps recursion depth', () => {
        const b = new ScanBudget({ maxFiles: 1e9, maxDepth: 2, maxFileBytes: 1e9, timeBudgetMs: 1e9 });
        assert.equal(b.withinDepth(2), true);
        assert.equal(b.withinDepth(3), false);
        assert.equal(b.report().truncatedByDepthCap, true);
    });
    it('default limits are positive and finite', () => {
        const l = defaultScanLimits();
        for (const v of [l.maxFiles, l.maxDepth, l.maxFileBytes, l.timeBudgetMs]) {
            assert.ok(Number.isFinite(v) && v > 0);
        }
    });
});
describe('scanAgentDirectory honours the ScanBudget (wiring)', () => {
    let dir;
    before(() => {
        dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cxni-scan-cap-'));
        // 12 detectable agent files (each imports an AI provider).
        for (let i = 0; i < 12; i++) {
            fs.writeFileSync(path.join(dir, `agent-${i}.ts`), `import Anthropic from '@anthropic-ai/sdk';\nconst c = new Anthropic();\n`);
        }
    });
    after(() => fs.rmSync(dir, { recursive: true, force: true }));
    it('stops visiting after the file-count cap and reports truncation', () => {
        const budget = new ScanBudget({ maxFiles: 4, maxDepth: 50, maxFileBytes: 1e9, timeBudgetMs: 1e9 });
        const results = scanAgentDirectory(dir, budget);
        // No more than the cap of files may be visited/returned.
        assert.ok(results.length <= 4, `expected <= 4 detected, got ${results.length}`);
        assert.equal(budget.report().truncatedByFileCap, true);
        assert.equal(budget.report().capped, true);
    });
    it('skips oversize files instead of reading them', () => {
        const big = path.join(dir, 'huge-agent.ts');
        fs.writeFileSync(big, `import OpenAI from 'openai';\n` + 'x'.repeat(5000));
        const budget = new ScanBudget({ maxFiles: 1e9, maxDepth: 50, maxFileBytes: 1024, timeBudgetMs: 1e9 });
        scanAgentDirectory(dir, budget);
        assert.ok(budget.report().filesSkippedOversize >= 1, 'oversize file must be skipped');
    });
    it('does not run signature regexes over a multi-MB single line (ReDoS/CPU guard)', () => {
        // A provider import that lives ONLY on a line longer than the per-line cap
        // is intentionally NOT analysed — that line is skipped, so no O(line)×N
        // regex pass happens. (A real import line is always short.)
        const giantOnly = fs.mkdtempSync(path.join(os.tmpdir(), 'cxni-scan-giant-'));
        try {
            fs.writeFileSync(path.join(giantOnly, 'minified-only.ts'), `import Anthropic from '@anthropic-ai/sdk';` + ' '.repeat(50_000) + ';\n');
            const budget = new ScanBudget({ maxFiles: 1e9, maxDepth: 50, maxFileBytes: 1e9, timeBudgetMs: 1e9 });
            const results = scanAgentDirectory(giantOnly, budget);
            assert.equal(results.length, 0, 'an import that exists only on an over-cap line must not be detected (line skipped)');
        }
        finally {
            fs.rmSync(giantOnly, { recursive: true, force: true });
        }
    });
    it('a short import on a file that also has a giant line is still detected', () => {
        const mixedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cxni-scan-mixed-'));
        try {
            fs.writeFileSync(path.join(mixedDir, 'mixed.ts'), `import OpenAI from 'openai';\n` + 'x'.repeat(80_000) + '\n');
            const budget = new ScanBudget({ maxFiles: 1e9, maxDepth: 50, maxFileBytes: 1e9, timeBudgetMs: 1e9 });
            const results = scanAgentDirectory(mixedDir, budget);
            assert.equal(results.length, 1, 'short import line must still be detected past a giant line');
        }
        finally {
            fs.rmSync(mixedDir, { recursive: true, force: true });
        }
    });
    it('a symlink cycle does not cause an unbounded walk', () => {
        const loopDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cxni-scan-loop-'));
        try {
            fs.writeFileSync(path.join(loopDir, 'a.ts'), `import OpenAI from 'openai';\n`);
            // self-referential symlink: loopDir/loop -> loopDir
            try {
                fs.symlinkSync(loopDir, path.join(loopDir, 'loop'), 'dir');
            }
            catch {
                return; // platform without symlink permission — skip
            }
            const budget = new ScanBudget({ maxFiles: 1000, maxDepth: 50, maxFileBytes: 1e9, timeBudgetMs: 5000 });
            const results = scanAgentDirectory(loopDir, budget);
            // Terminates (no hang) and does not re-descend the cycle: the single real
            // file is found exactly once, not thousands of times.
            assert.equal(results.length, 1, 'symlink target must not be re-walked');
            assert.equal(budget.report().truncatedByFileCap, false, 'cycle must not exhaust the file cap');
        }
        finally {
            fs.rmSync(loopDir, { recursive: true, force: true });
        }
    });
});
//# sourceMappingURL=scan-limits.test.js.map