/**
 * personas/role-scan.mjs — C48 deterministic role-signal detector.
 *
 * Scans the target project for role-ish signals that let the apt:personas
 * SKILL.md body (LLM) ground its persona drafts in real code — NOT invent
 * personas from prose alone.
 *
 * Design principle: no LLM call, no network, no heavy AST. This helper
 * returns a structured `findings[]` list. The SKILL.md body reads that list
 * AND the PROJECT.md target-audience prose AND .aperant/product-context.json
 * and produces the final persona JSON. If this file ever starts talking to
 * an LLM, the split is broken — move the LLM call up to the skill body
 * and keep this helper deterministic.
 *
 * Signals (ordered by discriminating power):
 *
 *   1. `role` / `userRole` / `isAdmin` / `isStaff` / `isOwner` identifier
 *      references — string constants, boolean checks, enum shapes. High
 *      confidence when the identifier is used in conditionals.
 *   2. `/api/<role>/` or `/<role>/` route prefixes in Next.js `app/` or
 *      Express-style path strings. Medium-high confidence.
 *   3. RBAC-shaped patterns: `Role`, `Permission`, `can*`, `hasRole`,
 *      `requireAuth`, `policy` naming. Medium confidence.
 *   4. Schema / validator enums: `v.union(v.literal('admin'), ...)` or
 *      `z.enum(['admin',...])`. High confidence — these are usually the
 *      canonical role list for the product.
 *
 * We cap scan to a bounded sample (fileCap=400 source files, lineCap=2000
 * lines per file) so the helper stays fast even on large monorepos. The
 * SKILL.md body reads the raw `findings[]` + decides what to escalate —
 * this is signal extraction, not classification.
 */
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { extname, join, resolve } from 'node:path';
const SOURCE_EXTENSIONS = new Set([
    '.ts',
    '.tsx',
    '.js',
    '.jsx',
    '.mjs',
    '.cjs',
    '.py',
    '.rs',
    '.go',
    '.java',
    '.rb',
]);
const IGNORE_DIRS = new Set([
    'node_modules',
    '.git',
    '.aperant',
    'dist',
    'build',
    '.next',
    '.turbo',
    '.vercel',
    '.cache',
    'target',
    'vendor',
    '__pycache__',
    'coverage',
    'out',
]);
/**
 * @typedef {Object} RoleFinding
 * @property {string} file            Repo-relative path.
 * @property {number} line            1-indexed line number where the signal appears.
 * @property {string} matched         The substring that matched.
 * @property {'identifier' | 'route-prefix' | 'rbac-pattern' | 'validator-enum'} kind
 * @property {'low' | 'medium' | 'high'} confidence
 */
/**
 * @typedef {Object} RoleScanResult
 * @property {string} projectDir
 * @property {number} files_scanned
 * @property {number} files_with_signal
 * @property {RoleFinding[]} findings
 * @property {string[]} unique_role_strings  De-duped role names seen in enums / literals
 * @property {{ truncated: boolean, reason: string | null }} limits
 */
const IDENTIFIER_RE = /\b(isAdmin|isStaff|isOwner|userRole|currentRole|roleName|user\.role|session\.user\.role)\b/;
const ROUTE_PREFIX_RE = /['"`](?:\/api)?\/(admin|owner|staff|user|member|student|teacher|coach|parent|guest|customer|buyer|seller|manager|doctor|patient|driver|rider|host|viewer|creator|artist|editor|reviewer|approver|superadmin|client|worker|vendor|contractor|employee|agent|operator)(?:\/|['"` ])/i;
const RBAC_RE = /\b(Role|Permission|canEdit|canView|canManage|hasRole|hasPermission|requireRole|requireAuth|authorize|policy(?:Id|Name)?)\b/;
const VALIDATOR_LITERAL_RE = /v\.literal\(\s*['"]([a-z][a-z_-]*)['"]\s*\)|z\.literal\(\s*['"]([a-z][a-z_-]*)['"]\s*\)/gi;
const VALIDATOR_ENUM_RE = /z\.enum\s*\(\s*\[([^\]]+)\]/gi;
/**
 * Walk a directory tree, yielding paths with interesting extensions. Bounded.
 *
 * @param {string} root
 * @param {number} fileCap
 * @returns {{ files: string[], truncated: boolean }}
 */
function collectSourceFiles(root, fileCap) {
    /** @type {string[]} */
    const out = [];
    /** @type {string[]} */
    const stack = [root];
    let truncated = false;
    while (stack.length > 0 && out.length < fileCap) {
        const dir = /** @type {string} */ (stack.pop());
        let entries;
        try {
            entries = readdirSync(dir, { withFileTypes: true });
        }
        catch {
            continue;
        }
        for (const entry of entries) {
            if (entry.name.startsWith('.') && IGNORE_DIRS.has(entry.name))
                continue;
            const full = join(dir, entry.name);
            if (entry.isDirectory()) {
                if (IGNORE_DIRS.has(entry.name))
                    continue;
                stack.push(full);
                continue;
            }
            if (!entry.isFile())
                continue;
            const ext = extname(entry.name);
            if (!SOURCE_EXTENSIONS.has(ext))
                continue;
            out.push(full);
            if (out.length >= fileCap) {
                truncated = true;
                break;
            }
        }
    }
    return { files: out, truncated };
}
/**
 * Turn an absolute path into a repo-relative one.
 *
 * @param {string} abs
 * @param {string} root
 */
function rel(abs, root) {
    if (abs.startsWith(root + '/'))
        return abs.slice(root.length + 1);
    if (abs === root)
        return '';
    return abs;
}
/**
 * Scan a single file and return role-signal findings. Bounded to the first
 * `lineCap` lines.
 *
 * @param {string} path
 * @param {string} repoRoot
 * @param {number} lineCap
 * @returns {RoleFinding[]}
 */
function scanFile(path, repoRoot, lineCap) {
    let text;
    try {
        text = readFileSync(path, 'utf-8');
    }
    catch {
        return [];
    }
    const lines = text.split(/\r?\n/).slice(0, lineCap);
    /** @type {RoleFinding[]} */
    const findings = [];
    const relPath = rel(path, repoRoot);
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        if (line.length === 0)
            continue;
        const idMatch = IDENTIFIER_RE.exec(line);
        if (idMatch) {
            findings.push({
                file: relPath,
                line: i + 1,
                matched: idMatch[0],
                kind: 'identifier',
                confidence: 'high',
            });
        }
        const routeMatch = ROUTE_PREFIX_RE.exec(line);
        if (routeMatch) {
            findings.push({
                file: relPath,
                line: i + 1,
                matched: routeMatch[0],
                kind: 'route-prefix',
                confidence: 'medium',
            });
        }
        // RBAC pattern matches are noisy; require the path to look policy-ish
        // or the identifier to be more than a bare `Role` word in a comment.
        const rbacMatch = RBAC_RE.exec(line);
        if (rbacMatch && !/^\s*(\/\/|#|\*|\/\*)/.test(line)) {
            findings.push({
                file: relPath,
                line: i + 1,
                matched: rbacMatch[0],
                kind: 'rbac-pattern',
                confidence: 'medium',
            });
        }
        // Validator literals are very high signal — every match IS a named role.
        VALIDATOR_LITERAL_RE.lastIndex = 0;
        let lit;
        lit = VALIDATOR_LITERAL_RE.exec(line);
        while (lit !== null) {
            const role = lit[1] ?? lit[2];
            if (role) {
                findings.push({
                    file: relPath,
                    line: i + 1,
                    matched: role,
                    kind: 'validator-enum',
                    confidence: 'high',
                });
            }
            lit = VALIDATOR_LITERAL_RE.exec(line);
        }
        VALIDATOR_ENUM_RE.lastIndex = 0;
        let enumMatch;
        enumMatch = VALIDATOR_ENUM_RE.exec(line);
        while (enumMatch !== null) {
            const body = enumMatch[1];
            const items = body
                .split(',')
                .map((x) => x.trim().replace(/^['"`]|['"`]$/g, ''))
                .filter((x) => /^[a-z][a-z_-]*$/.test(x));
            for (const item of items) {
                findings.push({
                    file: relPath,
                    line: i + 1,
                    matched: item,
                    kind: 'validator-enum',
                    confidence: 'high',
                });
            }
            enumMatch = VALIDATOR_ENUM_RE.exec(line);
        }
    }
    return findings;
}
/**
 * Run a bounded role-signal scan over a project directory.
 *
 * @param {string} projectDir
 * @param {{ fileCap?: number, lineCap?: number }} [opts]
 * @returns {RoleScanResult}
 */
export function runRoleScan(projectDir, opts = {}) {
    const abs = resolve(projectDir);
    const fileCap = opts.fileCap ?? 400;
    const lineCap = opts.lineCap ?? 2000;
    let stats;
    try {
        stats = statSync(abs);
    }
    catch {
        return {
            projectDir: abs,
            files_scanned: 0,
            files_with_signal: 0,
            findings: [],
            unique_role_strings: [],
            limits: { truncated: false, reason: 'project dir missing' },
        };
    }
    if (!stats.isDirectory()) {
        return {
            projectDir: abs,
            files_scanned: 0,
            files_with_signal: 0,
            findings: [],
            unique_role_strings: [],
            limits: { truncated: false, reason: 'project path is not a directory' },
        };
    }
    const { files, truncated } = collectSourceFiles(abs, fileCap);
    /** @type {RoleFinding[]} */
    const all = [];
    const withSignal = new Set();
    for (const f of files) {
        const findings = scanFile(f, abs, lineCap);
        if (findings.length > 0) {
            withSignal.add(f);
            all.push(...findings);
        }
    }
    // De-duped role strings seen in high-confidence signals — the SKILL.md body
    // uses this as the shortlist of candidate role names.
    const uniq = new Set();
    for (const f of all) {
        if (f.kind !== 'validator-enum' && f.kind !== 'route-prefix')
            continue;
        const clean = f.matched
            .toLowerCase()
            .replace(/^['"`/]+|['"` ]+$/g, '')
            .replace(/^api\//, '');
        if (/^[a-z][a-z_-]{1,30}$/.test(clean))
            uniq.add(clean);
    }
    return {
        projectDir: abs,
        files_scanned: files.length,
        files_with_signal: withSignal.size,
        findings: all,
        unique_role_strings: [...uniq].sort(),
        limits: {
            truncated,
            reason: truncated ? `scan capped at ${fileCap} source files` : null,
        },
    };
}
//# sourceMappingURL=role-scan.mjs.map