"use strict";
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.python = void 0;
exports.parsePipShowOutput = parsePipShowOutput;
exports.buildPyTopLevelDepIndex = buildPyTopLevelDepIndex;
exports.parseRequirementsTxtTopLevels = parseRequirementsTxtTopLevels;
exports.parseCoveragePy = parseCoveragePy;
exports.extractPyImportsRaw = extractPyImportsRaw;
exports.resolvePyImportRaw = resolvePyImportRaw;
exports.findPyProjectVenvPython = findPyProjectVenvPython;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const coverage_1 = require("../analyzers/tools/coverage");
const osv_1 = require("../analyzers/tools/osv");
const runner_1 = require("../analyzers/tools/runner");
const walk_paths_1 = require("../analyzers/tools/walk-paths");
const walk_source_files_1 = require("../analyzers/tools/walk-source-files");
const run_tests_helper_1 = require("../analyzers/tools/run-tests-helper");
const semver_bump_1 = require("../analyzers/tools/semver-bump");
const tool_registry_1 = require("../analyzers/tools/tool-registry");
function stripPyComments(src) {
    return src.replace(/(^|[^\w"'])#[^\n]*/g, '$1');
}
function isFile(p) {
    try {
        return fs.statSync(p).isFile();
    }
    catch {
        return false;
    }
}
function toRel(abs, cwd) {
    return path.relative(cwd, abs).split(path.sep).join('/');
}
function hasPyFile(cwd) {
    // Depth-unlimited via the canonical walker. The previous depth-2
    // cap missed real Python monorepos (e.g. `services/<svc>/src/*.py`
    // layouts).
    //
    // Requires >=3 `.py` files when no Python manifest is present.
    // The earlier "any .py file anywhere" threshold over-activated on
    // polyglot repos where a single build-output artifact (e.g. a
    // WinForms desktop app shipping one staging `.py` under
    // `StagingArea/Debug/net9.0-windows/`) would otherwise flip the
    // python pack on. With the pack active, `dominantVocabulary`
    // would then pick its Django/Flask-shaped words over the actual
    // dominant language pack's vocabulary — surfaces as wrong-stack
    // prose ("0 views/services, 0 models" on a 2,995-file C# repo).
    //
    // Real Python codebases without a manifest (small scripts, ad-hoc
    // analysis dirs, learning projects) still activate above the
    // threshold — the bar moved from "any" to "non-trivial," not to
    // "requires manifest." Manifest-bearing repos always activate
    // regardless of file count via the caller's `fileExists` checks.
    return (0, walk_paths_1.walkPaths)(cwd, { extensions: ['.py'] }).length >= 3;
}
/**
 * Build the pip-audit invocation for the project at `cwd`. Returns null
 * when no supported manifest is present — without one, pip-audit would
 * silently fall back to scanning its own python environment (the
 * dxkit-installed graphify-venv), reporting irrelevant vulnerabilities
 * against tools rather than against the project's declared dependencies.
 *
 * Manifest precedence:
 *   - pyproject.toml / setup.py → `pip-audit .` (project mode reads the
 *     declared dependencies)
 *   - requirements.txt          → `pip-audit -r requirements.txt`
 *   - Pipfile                   → unsupported by pip-audit natively; we
 *     return null rather than scan the wrong environment.
 */
function buildPipAuditCommand(cwd, pipAuditPath) {
    if ((0, runner_1.fileExists)(cwd, 'pyproject.toml') || (0, runner_1.fileExists)(cwd, 'setup.py')) {
        return `${pipAuditPath} . --format json`;
    }
    if ((0, runner_1.fileExists)(cwd, 'requirements.txt')) {
        return `${pipAuditPath} -r requirements.txt --format json`;
    }
    return null;
}
/**
 * Pure parser for `pip show pkg1 pkg2 ...` output. pip-show returns
 * RFC-822-ish blocks separated by `---` with `Key: Value` lines. The
 * fields we need:
 *
 *   Name: foo
 *   Requires: a, b, c
 *   Required-by: x, y
 *
 * Empty `Required-by` identifies a package that nothing else in the
 * env depends on — conventionally a directly-installed (top-level)
 * package. Extracted so the builder below can be unit-tested without
 * a live pip invocation.
 */
function parsePipShowOutput(raw) {
    const graph = new Map();
    // pip separates blocks with a line containing exactly '---'.
    const blocks = raw.split(/^---\s*$/m);
    for (const block of blocks) {
        let name = null;
        let requires = [];
        let requiredBy = [];
        for (const line of block.split('\n')) {
            const m = line.match(/^(Name|Requires|Required-by):\s*(.*)$/i);
            if (!m)
                continue;
            const key = m[1].toLowerCase();
            const val = m[2].trim();
            if (key === 'name')
                name = val;
            else if (key === 'requires') {
                requires = val
                    .split(',')
                    .map((s) => s.trim())
                    .filter(Boolean);
            }
            else if (key === 'required-by') {
                requiredBy = val
                    .split(',')
                    .map((s) => s.trim())
                    .filter(Boolean);
            }
        }
        if (name)
            graph.set(name, { requires, requiredBy });
    }
    return graph;
}
/**
 * BFS the parsed pip-show graph to produce a per-package-name index of
 * its top-level ancestors. Top-levels are packages with empty
 * `Required-by` — nothing else in the environment depends on them, so
 * they were installed as direct deps.
 *
 * Pure function; testable with fabricated graphs.
 */
function buildPyTopLevelDepIndex(graph) {
    const topLevels = [];
    for (const [name, meta] of graph) {
        if (meta.requiredBy.length === 0)
            topLevels.push(name);
    }
    const result = new Map();
    for (const top of topLevels) {
        const visited = new Set();
        const queue = [top];
        while (queue.length > 0) {
            const name = queue.shift();
            if (visited.has(name))
                continue;
            visited.add(name);
            const bucket = result.get(name) ?? new Set();
            bucket.add(top);
            result.set(name, bucket);
            const meta = graph.get(name);
            if (!meta)
                continue;
            for (const child of meta.requires) {
                if (!visited.has(child))
                    queue.push(child);
            }
        }
    }
    const sorted = new Map();
    for (const [k, v] of result)
        sorted.set(k, [...v].sort());
    return sorted;
}
/**
 * Parse a `requirements.txt`-style file into the set of direct (top-
 * level) package names. Used as a fallback when no venv is available
 * for the pip-show-based dep graph. Handles common requirement-spec
 * shapes:
 *
 *   requests==2.20.0           → 'requests'
 *   requests>=2.20,<3          → 'requests'
 *   Django                     → 'django'  (lowercased to match pip-audit)
 *   # comment                  → skipped
 *   -r other.txt               → skipped (recursive include not followed)
 *   -e .                       → skipped
 *   pkg ; python_version<'3'   → 'pkg'  (env-marker stripped)
 *   pkg[extra1,extra2]         → 'pkg'  (extras stripped)
 *
 * Names are lowercased to match pip-audit's canonical-PyPI casing
 * convention. Returns deduplicated names in the order they appear.
 */
function parseRequirementsTxtTopLevels(raw) {
    const out = [];
    const seen = new Set();
    for (const rawLine of raw.split(/\r?\n/)) {
        const line = rawLine.trim();
        if (!line || line.startsWith('#') || line.startsWith('-'))
            continue;
        // Strip inline `;` environment markers — they're conditions, not
        // part of the package spec.
        const beforeMarker = line.split(';')[0].trim();
        // Capture the leading package-name token, allowing the standard PEP
        // 508 character set ([A-Za-z0-9._-]+). Stop at the first specifier
        // (==, >=, <=, ~=, !=, <, >, [, space).
        const m = beforeMarker.match(/^([A-Za-z0-9._-]+)/);
        if (!m)
            continue;
        const name = m[1].toLowerCase();
        if (seen.has(name))
            continue;
        seen.add(name);
        out.push(name);
    }
    return out;
}
/** coverage.py JSON: `{ "totals": {...}, "files": { "path": { summary: {...} } } }`. */
function parseCoveragePy(raw, sourceFile, cwd) {
    const data = JSON.parse(raw);
    const files = new Map();
    let totalCovered = 0;
    let totalStatements = 0;
    for (const [key, entry] of Object.entries(data.files ?? {})) {
        const summary = entry?.summary;
        if (!summary)
            continue;
        const total = typeof summary.num_statements === 'number' ? summary.num_statements : 0;
        const missing = typeof summary.missing_lines === 'number' ? summary.missing_lines : 0;
        const covered = typeof summary.covered_lines === 'number'
            ? summary.covered_lines
            : Math.max(0, total - missing);
        const rel = (0, coverage_1.toRelative)(key, cwd);
        files.set(rel, {
            path: rel,
            covered,
            total,
            pct: (0, coverage_1.round1)(typeof summary.percent_covered === 'number'
                ? summary.percent_covered
                : total > 0
                    ? (covered / total) * 100
                    : 0),
        });
        totalCovered += covered;
        totalStatements += total;
    }
    const linePercent = typeof data.totals?.percent_covered === 'number'
        ? (0, coverage_1.round1)(data.totals.percent_covered)
        : (0, coverage_1.round1)(totalStatements > 0 ? (totalCovered / totalStatements) * 100 : 0);
    return { source: 'coverage-py', sourceFile, linePercent, files };
}
/**
 * Invoke `pip list` + `pip show --all` against the project's venv and
 * build the top-level attribution index. When no venv is present, fall
 * back to parsing `requirements.txt` directly — that gives us at least
 * direct-dep self-attribution (pkg → [pkg]) even when the project has
 * never been `pip install`ed. Surfaced by the cross-ecosystem benchmark
 * fixture (Phase 10h.6.8): a `requirements.txt`-only directory yielded
 * empty `topLevelDep` on every direct dep.
 */
function loadPyTopLevelDepIndex(cwd) {
    const venvPython = findPyProjectVenvPython(cwd);
    if (venvPython) {
        const listRaw = (0, runner_1.run)(`${venvPython} -m pip list --format=json`, cwd, 60000);
        if (listRaw) {
            try {
                const list = JSON.parse(listRaw);
                const names = list.map((x) => x.name).filter((n) => !!n);
                if (names.length > 0) {
                    // pip show accepts multiple package names in one call; stays
                    // well under shell arg-length limits for realistic envs
                    // (O(100) pkgs).
                    const showRaw = (0, runner_1.run)(`${venvPython} -m pip show ${names.join(' ')}`, cwd, 60000);
                    if (showRaw)
                        return buildPyTopLevelDepIndex(parsePipShowOutput(showRaw));
                }
            }
            catch {
                /* fall through to requirements.txt fallback */
            }
        }
    }
    // Fallback: parse requirements.txt directly. Only direct deps get
    // attribution; transitives stay unset (would need resolved metadata
    // from a pip-installed env to attribute, which we explicitly don't
    // have here).
    if (!(0, runner_1.fileExists)(cwd, 'requirements.txt'))
        return new Map();
    let raw;
    try {
        raw = fs.readFileSync(path.join(cwd, 'requirements.txt'), 'utf-8');
    }
    catch {
        return new Map();
    }
    const topLevels = parseRequirementsTxtTopLevels(raw);
    const result = new Map();
    for (const name of topLevels)
        result.set(name, [name]);
    return result;
}
/**
 * Single source of truth for the python pack's dep-vuln gathering.
 * Consumed by `pyDepVulnsProvider` (capability dispatcher).
 */
async function gatherPyDepVulnsResult(cwd) {
    const pipAudit = (0, tool_registry_1.findTool)(tool_registry_1.TOOL_DEFS['pip-audit'], cwd);
    if (!pipAudit.available || !pipAudit.path) {
        return { kind: 'unavailable', reason: 'pip-audit not installed' };
    }
    const cmd = buildPipAuditCommand(cwd, pipAudit.path);
    if (!cmd) {
        return { kind: 'no-manifest', reason: 'no pyproject.toml / setup.py / requirements.txt' };
    }
    const raw = (0, runner_1.run)(cmd, cwd, 120000);
    if (!raw)
        return { kind: 'unavailable', reason: 'pip-audit produced no output' };
    try {
        const data = JSON.parse(raw);
        const vulnIds = [];
        for (const dep of data.dependencies || []) {
            for (const v of dep.vulns || []) {
                if (v.id)
                    vulnIds.push(v.id);
            }
        }
        // pip-audit doesn't carry severity or CVSS per vuln — look up via
        // OSV.dev. Unknown/unreachable IDs fall back to medium (pip-audit's
        // legacy default). cvssScore is attached to findings when available.
        const enriched = await (0, osv_1.enrichOsv)(vulnIds);
        let critical = 0;
        let high = 0;
        let medium = 0;
        let low = 0;
        let enrichedCount = 0;
        const resolveSeverity = (id) => {
            const detail = enriched.get(id);
            const sev = detail?.severity;
            if (sev && sev !== 'unknown') {
                enrichedCount++;
                if (sev === 'critical')
                    return 'critical';
                if (sev === 'high')
                    return 'high';
                if (sev === 'medium')
                    return 'medium';
                return 'low';
            }
            return 'medium';
        };
        // Lazy-built: only load the top-level index when we have at least
        // one finding. Saves a pip list + pip show invocation on clean
        // projects. The index is built once even if multiple findings
        // reference the same package.
        let topLevelIndex = null;
        const getTopLevel = (pkg) => {
            if (topLevelIndex === null)
                topLevelIndex = loadPyTopLevelDepIndex(cwd);
            const parents = topLevelIndex.get(pkg);
            return parents && parents.length > 0 ? parents : undefined;
        };
        const findings = [];
        // pip-audit emits the same advisory once per affected-version range,
        // so a single CVE on a package can show up multiple times — same
        // (package, version, id), same fingerprint. Dedup at the source so
        // downstream consumers don't see synthetic duplicates. Surfaced by
        // requests@2.20.0 in the cross-ecosystem benchmark fixture (Phase
        // 10h.6.8) where PYSEC-2023-74 was emitted twice.
        const seen = new Set();
        for (const dep of data.dependencies || []) {
            for (const v of dep.vulns || []) {
                if (!v.id)
                    continue;
                const dedupKey = `${dep.name ?? 'unknown'}\0${dep.version ?? ''}\0${v.id}`;
                if (seen.has(dedupKey))
                    continue;
                seen.add(dedupKey);
                const severity = resolveSeverity(v.id);
                if (severity === 'critical')
                    critical++;
                else if (severity === 'high')
                    high++;
                else if (severity === 'medium')
                    medium++;
                else
                    low++;
                const finding = {
                    id: v.id,
                    package: dep.name ?? 'unknown',
                    installedVersion: dep.version,
                    tool: 'pip-audit',
                    packId: 'python',
                    severity,
                };
                // Embedded score from the primary enrichment pass; alias-fallback
                // happens in the batched resolveCvssScores call after this loop.
                const primaryCvss = enriched.get(v.id)?.cvssScore;
                if (primaryCvss !== null && primaryCvss !== undefined)
                    finding.cvssScore = primaryCvss;
                // pip-audit returns sorted fix versions ascending; the first is the
                // minimal upgrade that resolves the issue.
                if (v.fix_versions && v.fix_versions.length > 0) {
                    finding.fixedVersion = v.fix_versions[0];
                    // Tier-2 structured plan (10h.6.2): Python's dep graph is flat —
                    // the fix is always an upgrade of the package itself, so parent
                    // == finding.package. `patches[]` carries just this advisory's
                    // id (pip-audit doesn't roll up "one upgrade fixes N advisories"
                    // the way osv-scanner does on npm). `breaking` derives from a
                    // major-version jump from the installed version to the target.
                    finding.upgradePlan = {
                        parent: finding.package,
                        parentVersion: v.fix_versions[0],
                        patches: [v.id],
                        breaking: (0, semver_bump_1.isMajorBump)(dep.version ?? '', v.fix_versions[0]),
                    };
                }
                // Filter empty alias entries — ECHO advisories occasionally emit [].
                const aliases = (v.aliases ?? []).filter((a) => a && a.length > 0);
                if (aliases.length > 0)
                    finding.aliases = aliases;
                if (v.description)
                    finding.summary = v.description;
                // pip-audit doesn't carry advisory URLs; OSV.dev hosts a canonical
                // page per vulnerability id, so synthesize the reference. Render
                // layer can override if a tool-supplied URL becomes available later.
                finding.references = [`https://osv.dev/vulnerability/${v.id}`];
                const parents = getTopLevel(finding.package);
                if (parents)
                    finding.topLevelDep = parents;
                findings.push(finding);
            }
        }
        // Alias-fallback CVSS pass: PYSEC-* records on OSV.dev frequently
        // lack CVSS vectors that the corresponding CVE alias carries (the
        // pip ecosystem assigns PYSEC ids before CVEs land). When primary
        // lookup misses, we re-query each alias to fill the score.
        if (findings.length > 0) {
            const cvssInputs = findings.map((f) => ({
                primaryId: f.id,
                embeddedCvss: f.cvssScore ?? null,
                aliases: f.aliases ?? [],
            }));
            const resolved = await (0, osv_1.resolveCvssScores)(cvssInputs);
            for (const f of findings) {
                const score = resolved.get(f.id);
                if (score !== null && score !== undefined)
                    f.cvssScore = score;
            }
        }
        const envelope = {
            schemaVersion: 1,
            tool: 'pip-audit',
            enrichment: enrichedCount > 0 ? 'osv.dev' : null,
            counts: { critical, high, medium, low },
            findings,
        };
        return { kind: 'success', envelope };
    }
    catch (err) {
        return { kind: 'unavailable', reason: `pip-audit parse error: ${err.message}` };
    }
}
const pyDepVulnsProvider = {
    source: 'python',
    async gather(cwd) {
        const outcome = await gatherPyDepVulnsResult(cwd);
        return outcome.kind === 'success' ? outcome.envelope : null;
    },
    async gatherOutcome(cwd) {
        return gatherPyDepVulnsResult(cwd);
    },
};
function mapRuffSeverity(code) {
    const prefix = code.match(/^[A-Z]+/)?.[0] ?? '';
    switch (prefix) {
        case 'S':
            return 'critical';
        case 'F':
        case 'B':
            return 'high';
        case 'E':
        case 'C':
            return 'medium';
        default:
            return 'low';
    }
}
/**
 * Single source of truth for the python pack's lint gathering.
 * Consumed by `pyLintProvider` (capability dispatcher).
 */
function gatherPyLintResult(cwd) {
    const ruff = (0, tool_registry_1.findTool)(tool_registry_1.TOOL_DEFS.ruff, cwd);
    if (!ruff.available || !ruff.path) {
        return { kind: 'unavailable', reason: 'not installed' };
    }
    const raw = (0, runner_1.run)(`${ruff.path} check . --output-format json`, cwd, 60000);
    const counts = { critical: 0, high: 0, medium: 0, low: 0 };
    if (!raw) {
        // Empty output = ruff ran and found nothing, matching prior behavior.
        const envelope = { schemaVersion: 1, tool: 'ruff', counts };
        return { kind: 'success', envelope };
    }
    try {
        const results = JSON.parse(raw);
        if (!Array.isArray(results)) {
            return { kind: 'unavailable', reason: 'parse error' };
        }
        for (const r of results) {
            counts[mapRuffSeverity(r.code)]++;
        }
        const envelope = { schemaVersion: 1, tool: 'ruff', counts };
        return { kind: 'success', envelope };
    }
    catch {
        return { kind: 'unavailable', reason: 'parse error' };
    }
}
const pyLintProvider = {
    source: 'python',
    async gather(cwd) {
        const outcome = gatherPyLintResult(cwd);
        return outcome.kind === 'success' ? outcome.envelope : null;
    },
    async gatherOutcome(cwd) {
        return gatherPyLintResult(cwd);
    },
};
/**
 * Single source of truth for the python pack's coverage gathering.
 * Consumed by `pyCoverageProvider` (capability dispatcher).
 */
function gatherPyCoverageResult(cwd) {
    const file = path.join(cwd, 'coverage.json');
    let raw;
    try {
        raw = fs.readFileSync(file, 'utf-8');
    }
    catch {
        return null;
    }
    try {
        const coverage = parseCoveragePy(raw, 'coverage.json', cwd);
        return { schemaVersion: 1, tool: `coverage:${coverage.source}`, coverage };
    }
    catch {
        return null;
    }
}
/**
 * Run `python -m pytest --cov --cov-report=json:coverage.json` from cwd
 * (D021). Prefer the project's own venv python (`.venv`, poetry, pipenv)
 * when resolvable so the test run uses the env that has pytest +
 * pytest-cov installed — falling back to the user's PATH `python`
 * otherwise.
 *
 * Preflight: require at least one Python manifest (pyproject.toml,
 * setup.py, requirements.txt, Pipfile). Without one, this is almost
 * certainly not a Python project (or it's a script-only directory that
 * would need explicit `--lang python` to opt in).
 *
 * Note: pytest-cov plugin presence is NOT preflighted — there is no
 * TOOL_DEFS entry for it, and the spawn outcome is sufficiently clear
 * when it's missing (`pytest: unrecognized arguments: --cov`). The
 * `coverage-py` tool is registry-tracked but the design-doc command
 * uses pytest-cov, not standalone coverage.py.
 */
function runPyTestsWithCoverage(cwd) {
    return Promise.resolve((0, run_tests_helper_1.runTestsWithCoverage)({
        pack: 'python',
        cmd: (() => {
            const venvPython = findPyProjectVenvPython(cwd);
            const py = venvPython ?? 'python';
            return `${py} -m pytest --cov --cov-report=json:coverage.json`;
        })(),
        cwd,
        artifact: 'coverage.json',
        preflight: (cwd) => {
            const hasManifest = (0, runner_1.fileExists)(cwd, 'pyproject.toml') ||
                (0, runner_1.fileExists)(cwd, 'setup.py') ||
                (0, runner_1.fileExists)(cwd, 'requirements.txt') ||
                (0, runner_1.fileExists)(cwd, 'Pipfile');
            if (!hasManifest) {
                return 'no Python project manifest (pyproject.toml/setup.py/requirements.txt/Pipfile) in this directory';
            }
            return null;
        },
    }));
}
const pyCoverageProvider = {
    source: 'python',
    async gather(cwd) {
        return gatherPyCoverageResult(cwd);
    },
    async runTests(cwd) {
        return runPyTestsWithCoverage(cwd);
    },
};
/**
 * Capture Python module specifiers from source text. Handles both
 * `from X import Y` and bare `import X, Y as Z` forms. Exported so
 * unit tests can exercise it directly; the imports capability batches
 * it across all .py files under the repo.
 */
function extractPyImportsRaw(content) {
    const out = [];
    for (const line of stripPyComments(content).split('\n')) {
        const trimmed = line.trim();
        const fromMatch = trimmed.match(/^from\s+([.\w]+)\s+import\s+/);
        if (fromMatch) {
            out.push(fromMatch[1]);
            continue;
        }
        const impMatch = trimmed.match(/^import\s+(.+)$/);
        if (impMatch) {
            for (const part of impMatch[1].split(',')) {
                const name = part
                    .trim()
                    .split(/\s+as\s+/)[0]
                    .trim();
                if (name)
                    out.push(name);
            }
        }
    }
    return out;
}
/**
 * Resolve a Python import specifier to an in-project file path, or null
 * for unresolvable / stdlib / external references. Handles relative
 * specifiers (leading dots) and absolute-from-project-root imports.
 * Exported for unit testing.
 */
function resolvePyImportRaw(fromFile, spec, cwd) {
    const fromDir = path.dirname(path.join(cwd, fromFile));
    const dotMatch = spec.match(/^(\.+)(.*)$/);
    let baseDir;
    let remainder;
    if (dotMatch) {
        const dots = dotMatch[1].length;
        remainder = dotMatch[2];
        baseDir = fromDir;
        for (let i = 1; i < dots; i++)
            baseDir = path.dirname(baseDir);
    }
    else {
        baseDir = cwd;
        remainder = spec;
    }
    if (!remainder)
        return null;
    const parts = remainder.split('.').filter(Boolean);
    const candidate = path.join(baseDir, ...parts);
    if (isFile(candidate + '.py'))
        return toRel(candidate + '.py', cwd);
    const init = path.join(candidate, '__init__.py');
    if (isFile(init))
        return toRel(init, cwd);
    return null;
}
/**
 * Enumerate .py source files under cwd and pre-compute the pack's
 * per-file imports + resolved edges. Shares enumeration strategy with
 * the typescript pack (the cross-platform `walkSourceFiles` walker).
 * `includeTests` + `includeAutogen` keep the file set identical to the
 * prior `find -name "*.py"` enumeration, which filtered neither.
 */
function gatherPyImportsResult(cwd) {
    const files = (0, walk_source_files_1.walkSourceFiles)(cwd, {
        extensions: ['.py'],
        includeTests: true,
        includeAutogen: true,
    });
    if (files.length === 0)
        return null;
    const extracted = new Map();
    const edges = new Map();
    for (const rel of files) {
        let content;
        try {
            content = fs.readFileSync(path.join(cwd, rel), 'utf-8');
        }
        catch {
            continue;
        }
        const specs = extractPyImportsRaw(content);
        extracted.set(rel, specs);
        const targets = new Set();
        for (const spec of specs) {
            const resolved = resolvePyImportRaw(rel, spec, cwd);
            if (resolved)
                targets.add(resolved);
        }
        if (targets.size > 0)
            edges.set(rel, targets);
    }
    if (extracted.size === 0)
        return null;
    return {
        schemaVersion: 1,
        tool: 'python-imports',
        sourceExtensions: ['.py'],
        extracted,
        edges,
    };
}
const pyImportsProvider = {
    source: 'python',
    async gather(cwd) {
        return gatherPyImportsResult(cwd);
    },
};
/**
 * Detect pytest by the three signals it publishes: `pytest.ini`,
 * `conftest.py`, or a `[tool.pytest]` table in `pyproject.toml`. Other
 * Python runners (unittest, nose) don't self-announce and would need
 * heuristic source-file scanning — out of scope until Phase 10f.1.
 * Returns null when no signal is present.
 */
function gatherPyTestFrameworkResult(cwd) {
    const hasPytestConfigFile = (0, runner_1.fileExists)(cwd, 'pytest.ini', 'conftest.py');
    if (hasPytestConfigFile)
        return { schemaVersion: 1, tool: 'python', name: 'pytest' };
    if ((0, runner_1.fileExists)(cwd, 'pyproject.toml')) {
        let pyproject = '';
        try {
            pyproject = fs.readFileSync(path.join(cwd, 'pyproject.toml'), 'utf-8');
        }
        catch {
            /* unreadable — treat as no signal */
        }
        if (pyproject.includes('[tool.pytest')) {
            return { schemaVersion: 1, tool: 'python', name: 'pytest' };
        }
    }
    return null;
}
const pyTestFrameworkProvider = {
    source: 'python',
    async gather(cwd) {
        return gatherPyTestFrameworkResult(cwd);
    },
};
/**
 * Check whether an absolute path points at a runnable Python
 * interpreter (a file; Windows `python.exe` not yet supported —
 * matches the pre-10h.4.4 behavior).
 */
function isPyExecutable(abs) {
    try {
        return fs.statSync(abs).isFile();
    }
    catch {
        return false;
    }
}
/**
 * Given a candidate venv root directory, return the path to its
 * `bin/python[3]` binary if present, or null otherwise. Extracted so
 * the fallback chain below can reuse it across detection strategies.
 */
function venvRootToPython(venvRoot) {
    for (const exe of ['python', 'python3']) {
        const candidate = path.join(venvRoot, 'bin', exe);
        if (isPyExecutable(candidate))
            return candidate;
    }
    return null;
}
/**
 * Locate the project's own Python interpreter.
 *
 * Detection order (fail-cheap first, subprocess last):
 *   1. `./.venv` or `./venv` under cwd  — uv default, in-project
 *      poetry (`virtualenvs.in-project = true`), hand-rolled venvs.
 *      Zero-cost stat() checks.
 *   2. `$VIRTUAL_ENV` env var — set by `source .venv/bin/activate`
 *      and poetry shell. Catches externally-activated envs.
 *   3. `poetry env info --path` — external poetry venvs
 *      (default `virtualenvs.in-project = false` on most installs;
 *      env lives in `~/.cache/pypoetry/virtualenvs/<hash>`).
 *   4. `pipenv --venv` — pipenv default stores elsewhere too.
 *
 * Returns null if no venv is resolvable — the provider then skips
 * cleanly rather than falling through to pip-licenses's install env
 * (which would report dxkit's own packages, not the project's).
 */
function findPyProjectVenvPython(cwd) {
    // 1. Conventional in-project venv roots (fast path).
    for (const dir of ['.venv', 'venv']) {
        const p = venvRootToPython(path.join(cwd, dir));
        if (p)
            return p;
    }
    // 2. Caller-activated env (poetry shell, source activate, etc.).
    const active = process.env.VIRTUAL_ENV;
    if (active) {
        const p = venvRootToPython(active);
        if (p)
            return p;
    }
    // 3. External poetry venv. `poetry env info --path` returns the
    // active env's root directory on stdout or nothing if no env.
    const poetryPath = (0, runner_1.run)('poetry env info --path', cwd, 10000).trim();
    if (poetryPath) {
        const p = venvRootToPython(poetryPath);
        if (p)
            return p;
    }
    // 4. External pipenv venv.
    const pipenvPath = (0, runner_1.run)('pipenv --venv', cwd, 10000).trim();
    if (pipenvPath) {
        const p = venvRootToPython(pipenvPath);
        if (p)
            return p;
    }
    return null;
}
/**
 * Single source of truth for the python pack's license gathering.
 * Consumed by `pyLicensesProvider` (capability dispatcher).
 *
 * Gating is strict — both a Python manifest AND a project venv are
 * required. Reason: pip-licenses operates on the active Python
 * environment, so invoking it without `--python <project-venv>` would
 * report whatever packages are installed in dxkit's graphify-venv
 * (i.e. garbage). Returns null when either prerequisite is missing so
 * the dispatcher drops the pack cleanly.
 */
function gatherPyLicensesResult(cwd) {
    const hasManifest = (0, runner_1.fileExists)(cwd, 'pyproject.toml') ||
        (0, runner_1.fileExists)(cwd, 'setup.py') ||
        (0, runner_1.fileExists)(cwd, 'requirements.txt') ||
        (0, runner_1.fileExists)(cwd, 'Pipfile');
    if (!hasManifest) {
        return {
            kind: 'no-manifest',
            reason: 'no pyproject.toml / setup.py / requirements.txt / Pipfile',
        };
    }
    const venvPython = findPyProjectVenvPython(cwd);
    if (!venvPython) {
        return { kind: 'unavailable', reason: 'no resolvable project venv python' };
    }
    const status = (0, tool_registry_1.findTool)(tool_registry_1.TOOL_DEFS['pip-licenses'], cwd);
    if (!status.available || !status.path) {
        return { kind: 'unavailable', reason: 'pip-licenses not installed' };
    }
    const raw = (0, runner_1.run)(`${status.path} --python ${venvPython} --format=json --with-license-file --no-license-path --with-description --with-urls --with-authors`, cwd, 120000);
    if (!raw)
        return { kind: 'unavailable', reason: 'pip-licenses produced no output' };
    let data;
    try {
        data = JSON.parse(raw);
    }
    catch (err) {
        return { kind: 'unavailable', reason: `pip-licenses parse error: ${err.message}` };
    }
    if (!Array.isArray(data)) {
        return { kind: 'unavailable', reason: 'pip-licenses output was not a JSON array' };
    }
    // Top-level attribution reuses the same pip-show-graph BFS the
    // depVulns path builds. Self-parent invariant (`index[top]` contains
    // `top`) classifies every row without a second `pip show` pass.
    // Empty index (pip tools missing, venv gone) leaves isTopLevel unset.
    const topLevelIndex = loadPyTopLevelDepIndex(cwd);
    const hasIndex = topLevelIndex.size > 0;
    const findings = [];
    for (const entry of data) {
        if (!entry.Name || !entry.Version)
            continue;
        const licenseType = entry.License && entry.License !== 'UNKNOWN' ? entry.License : 'UNKNOWN';
        const parents = hasIndex ? topLevelIndex.get(entry.Name) : undefined;
        findings.push({
            package: entry.Name,
            version: entry.Version,
            licenseType,
            licenseText: entry.LicenseText && entry.LicenseText !== 'UNKNOWN' ? entry.LicenseText : undefined,
            sourceUrl: entry.URL && entry.URL !== 'UNKNOWN' ? entry.URL : undefined,
            description: entry.Description && entry.Description !== 'UNKNOWN' ? entry.Description : undefined,
            supplier: entry.Author && entry.Author !== 'UNKNOWN' ? entry.Author : undefined,
            isTopLevel: hasIndex ? (parents?.includes(entry.Name) ?? false) : undefined,
        });
    }
    const envelope = {
        schemaVersion: 1,
        tool: 'pip-licenses',
        findings,
    };
    return { kind: 'success', envelope };
}
const pyLicensesProvider = {
    source: 'python',
    async gather(cwd) {
        const outcome = gatherPyLicensesResult(cwd);
        return outcome.kind === 'success' ? outcome.envelope : null;
    },
    async gatherOutcome(cwd) {
        return gatherPyLicensesResult(cwd);
    },
};
exports.python = {
    id: 'python',
    displayName: 'Python',
    commentSyntax: { lineComment: '#' },
    sourceExtensions: ['.py'],
    testFilePatterns: ['test_*.py', '*_test.py'],
    extraExcludes: ['__pycache__', '.pytest_cache', '.ruff_cache', '.venv', 'venv', '.mypy_cache'],
    exportDetection: {
        reliability: 'partial',
        strategy: '`__all__` list when present, else public-name heuristic (identifier without leading underscore)',
    },
    // D027 (2.4.7): Python module/class/function docstrings use either
    // triple-double or triple-single quotes. Match the docstring opener
    // at the start of a (possibly indented) line.
    docCommentPatterns: ['^[[:space:]]*"""', "^[[:space:]]*'''"],
    // D034 (2.4.7): `requests` and `urllib3` TLS-bypass idioms.
    // `verify=False` is the canonical opt-out on every `requests` call;
    // `disable_warnings(InsecureRequestWarning)` typically accompanies it
    // to silence the runtime warning. `VERIFY_SSL=false` is a common
    // env-var convention in Django/Flask configs.
    tlsBypassPatterns: ['verify[[:space:]]*=[[:space:]]*False', 'VERIFY_SSL.*[Ff]alse'],
    upgradeCommand(name, version) {
        return `pip install '${name}==${version}'`;
    },
    // Django (views/viewsets/serializers), Flask/FastAPI (routers, api
    // endpoints), Celery (tasks) — declarations cover the dominant
    // server-side Python patterns. Plain library / data-science code
    // (where `services/` is rare) simply doesn't match anything and
    // the analyzer degrades to "no primary architecture detected" —
    // exactly the pre-extension behavior.
    architecturalShape: {
        primaryComponentPaths: [
            '/views/',
            '/viewsets/',
            '/handlers/',
            '/services/',
            '/api/',
            '/routers/',
            '/tasks/',
        ],
        routePaths: ['/views/', '/viewsets/', '/routers/', '/api/', '/urls.py'],
        modelPaths: ['/models/', '/schemas/', '/serializers/'],
        vocabulary: {
            components: 'views/services',
            models: 'models',
            routes: 'routes',
        },
        testGapPriority: {
            high: ['/views/', '/services/', '/handlers/', '/tasks/'],
            medium: ['/viewsets/', '/routers/', '/api/', '/serializers/'],
        },
    },
    clocLanguageNames: ['Python'],
    detect(cwd) {
        return ((0, runner_1.fileExists)(cwd, 'pyproject.toml') ||
            (0, runner_1.fileExists)(cwd, 'setup.py') ||
            (0, runner_1.fileExists)(cwd, 'requirements.txt') ||
            (0, runner_1.fileExists)(cwd, 'Pipfile') ||
            hasPyFile(cwd));
    },
    tools: ['ruff', 'pip-audit', 'coverage-py', 'pip-licenses'],
    semgrepRulesets: ['p/python'],
    capabilities: {
        depVulns: pyDepVulnsProvider,
        lint: pyLintProvider,
        coverage: pyCoverageProvider,
        imports: pyImportsProvider,
        testFramework: pyTestFrameworkProvider,
        licenses: pyLicensesProvider,
    },
    mapLintSeverity: mapRuffSeverity,
    permissions: ['Bash(python3:*)', 'Bash(pytest:*)', 'Bash(ruff:*)'],
    ruleFile: 'python.md',
    defaultVersion: '3.12',
    cliBinaries: ['python3', 'ruff'],
    devcontainerFeature: {
        name: 'ghcr.io/devcontainers/features/python:1',
        // installTools=false: the upstream python feature's installTools
        // bundle (pipx, flake8, black, mypy, autopep8, yapf, pydocstyle,
        // pycodestyle, bandit, pipenv, virtualenv, pylint, pytest) costs
        // ~3 min per devcontainer build and overlaps with dxkit's own
        // pinned scanner toolchain (ruff + pip-audit + pip-licenses +
        // coverage-py installed via TOOL_DEFS). Anything dxkit's Python
        // analyzers actually need is declared in tool-registry.ts; the
        // feature only needs to land the python interpreter itself.
        opts: { version: '3.12', installTools: false },
    },
    devcontainerExtensions: ['ms-python.python', 'ms-python.vscode-pylance'],
};
//# sourceMappingURL=python.js.map