/**
 * Cross-component imports graph (engines 6.32.6+).
 *
 * Phase A of Dom Williams's "shared library / reference vs implicit
 * include" feedback (2026-05-07). When the analysed source is a
 * monorepo with shared/ libraries (idle_meta has shared/services/,
 * shared/ui/, shared/data/, shared/schemas/), the prepass already
 * decomposes the source into one component per top-level dir — but
 * never models the dependency direction. `IdleApiService` ends up
 * with action steps like `[call] AuthService.login` without any
 * spec-level binding to the `Services` component that owns AuthService.
 *
 * This module walks each component's TS/JS files for `import { X } from
 * '...'` statements, resolves the import path relative to the source
 * tree, and matches the resolved path against the other components'
 * sourceDirs. Result: a map of consumer → (target component → set of
 * imported symbol names). The skeleton-emitter consumes that map to
 * emit `import:` blocks per consumer component.
 *
 * Detection is regex-based — same approach as the express-routes /
 * zod-schemas adapters. Good enough for ES module + CommonJS source;
 * misses dynamic `await import()` and `require(variable)` (rare in
 * spec-able codebases). Type-only imports are included; the realize
 * engine will ignore unused selects.
 */
/**
 * Extract all import statements from a TS/JS source file. Handles:
 *   - `import { A, B as C } from 'mod'` — named
 *   - `import D from 'mod'` — default
 *   - `import * as M from 'mod'` — namespace
 *   - `import 'mod'` — side-effect (returns names: [])
 *   - `import type { A } from 'mod'` — type-only
 *   - `const { A } = require('mod')` — CommonJS destructuring
 *
 * Returns an empty array on empty input. Skips comments and string
 * literals (basic heuristic — strips block comments and line comments).
 */
export function parseImports(source) {
    if (!source)
        return [];
    // Strip comments — naive but adequate; we don't need exact lex
    const cleaned = source
        .replace(/\/\*[\s\S]*?\*\//g, ' ')
        .replace(/(^|[^:])\/\/[^\n]*/g, '$1');
    const out = [];
    // ES module imports: `import [type] (...) from 'mod'`
    // Cases:
    //   import { A, B as C } from 'mod'
    //   import D from 'mod'
    //   import D, { A } from 'mod'
    //   import * as M from 'mod'
    //   import 'mod'                          (side-effect)
    //   import type { A } from 'mod'
    const esRe = /import\s+(type\s+)?(?:([\w$]+)(?:\s*,\s*\{([^}]*)\})?|(\{[^}]*\})|(\*\s+as\s+[\w$]+))?\s*(?:from\s+)?['"]([^'"]+)['"]/g;
    let m;
    while ((m = esRe.exec(cleaned)) !== null) {
        const typeOnly = !!m[1];
        const defaultName = m[2];
        const namedAfterDefault = m[3];
        const namedOnly = m[4];
        const namespaceForm = m[5];
        const moduleName = m[6];
        const names = [];
        let namespaceAlias;
        if (defaultName)
            names.push(defaultName);
        if (namedAfterDefault)
            names.push(...parseNamedSpecifiers(namedAfterDefault));
        if (namedOnly)
            names.push(...parseNamedSpecifiers(namedOnly.slice(1, -1)));
        if (namespaceForm) {
            const aliasMatch = namespaceForm.match(/\*\s+as\s+([\w$]+)/);
            if (aliasMatch)
                namespaceAlias = aliasMatch[1];
        }
        out.push({
            source: moduleName,
            names: dedup(names),
            ...(namespaceAlias ? { namespaceAlias } : {}),
            typeOnly,
        });
    }
    // CommonJS destructuring: `const { A, B } = require('mod')`
    const cjsDestructuringRe = /(?:const|let|var)\s+\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
    while ((m = cjsDestructuringRe.exec(cleaned)) !== null) {
        const names = parseNamedSpecifiers(m[1]);
        out.push({ source: m[2], names: dedup(names), typeOnly: false });
    }
    return out;
}
/** Parse `A, B as C, D` into ['A', 'B', 'D'] (uses imported name, not local alias). */
function parseNamedSpecifiers(raw) {
    return raw
        .split(',')
        .map((s) => s.trim())
        .filter(Boolean)
        .map((s) => s.split(/\s+as\s+/)[0].trim())
        .filter((s) => /^[\w$]+$/.test(s));
}
function dedup(arr) {
    return [...new Set(arr)];
}
/**
 * Resolve a relative import path against the file that contains the
 * import. Returns a normalised path (no `..` / `.` segments, no leading
 * `./`). Non-relative specifiers (`react`, `@scope/pkg`) return null
 * unless they match an alias in `aliases` (Engines 6.32.7+).
 *
 * The result is the path as it would appear in the source tree, NOT
 * the absolute disk path. Caller compares it against component
 * sourceDirs (which are also tree-relative) to see if the import
 * crosses a component boundary.
 *
 * @param aliases Optional map of alias resolutions from a tsconfig
 *                 `paths` block. Keys are alias patterns (e.g. `@shared/ui`,
 *                 `@/*`); values are pre-resolved tree-paths. The resolver
 *                 substitutes a longest-prefix match before falling back
 *                 to relative-only resolution.
 */
export function resolveImportPath(fromFile, importSpec, aliases = {}) {
    // 1. Try alias resolution first — longest-prefix match wins.
    const aliasKeys = Object.keys(aliases).sort((a, b) => b.length - a.length);
    for (const pattern of aliasKeys) {
        if (pattern.endsWith('/*')) {
            const stem = pattern.slice(0, -2);
            if (importSpec.startsWith(stem + '/') || importSpec === stem) {
                const tail = importSpec.slice(stem.length).replace(/^\//, '');
                const target = aliases[pattern].replace(/\/\*$/, '');
                return tail ? `${target}/${tail}` : target;
            }
        }
        else if (importSpec === pattern) {
            return aliases[pattern];
        }
        else if (importSpec.startsWith(pattern + '/')) {
            const tail = importSpec.slice(pattern.length + 1);
            return `${aliases[pattern]}/${tail}`;
        }
    }
    // 2. Relative resolution — `./X` or `../../X`.
    // Engines 6.32.10+: preserve leading `/` so absolute paths
    // (`/tmp/run/input/...`) round-trip cleanly. Earlier the empty-segment
    // filter dropped the leading slash, breaking startsWith() matches
    // against component sourceDirs (which keep their absolute prefix).
    if (!importSpec.startsWith('.'))
        return null;
    const fromDir = fromFile.replace(/\/[^/]*$/, '');
    const isAbsolute = fromFile.startsWith('/');
    const segments = (fromDir + '/' + importSpec).split('/');
    const stack = [];
    for (const seg of segments) {
        if (seg === '' || seg === '.')
            continue;
        if (seg === '..') {
            stack.pop();
            continue;
        }
        stack.push(seg);
    }
    return (isAbsolute ? '/' : '') + stack.join('/');
}
/**
 * Engines 6.32.7+ — parse the `compilerOptions.paths` block from a
 * tsconfig.json and return an absolute alias map.
 *
 * tsconfig paths are relative to the tsconfig's directory (or to
 * `compilerOptions.baseUrl` if set). This function resolves them to
 * tree-relative absolute paths so the import resolver can match them
 * against component sourceDirs without further normalisation.
 *
 * Returns an empty object when the tsconfig has no paths block, or
 * when parsing fails (best-effort — broken tsconfigs shouldn't break
 * the analyse pipeline).
 *
 * Note: paths entries like `[ "../../shared/ui/src/index.ts" ]` — we
 * strip the trailing `index.ts` segment so the alias maps to the
 * directory level (which is what the import resolver needs to match
 * against component sourceDirs).
 */
export function parseTsconfigPaths(tsconfigSource, tsconfigDir) {
    let parsed;
    try {
        parsed = JSON.parse(stripJsonComments(tsconfigSource));
    }
    catch {
        return {};
    }
    const co = parsed?.compilerOptions ?? {};
    const paths = co.paths ?? {};
    const baseUrl = co.baseUrl ?? '.';
    const baseDir = resolveJoin(tsconfigDir, baseUrl);
    const out = {};
    for (const [pattern, targets] of Object.entries(paths)) {
        if (!Array.isArray(targets) || targets.length === 0)
            continue;
        const target = targets[0];
        let resolved = resolveJoin(baseDir, target.replace(/\/\*$/, ''));
        // Strip trailing index file so the alias points at the directory.
        resolved = resolved.replace(/\/(index|main)\.(ts|tsx|js|jsx|mjs|cjs)$/, '');
        out[pattern] = resolved;
    }
    return out;
}
/**
 * String-aware comment stripper for JSON-with-comments (tsconfig.json
 * is the canonical case). Walks character by character, tracking
 * whether we're inside a string literal — comment delimiters inside
 * strings (e.g. paths like `"@lib/*"`) are preserved.
 *
 * Engines 6.32.7+. A previous regex-based approach choked on string
 * values containing wildcards because comment-style delimiters could
 * appear inside legitimate JSON keys like `"@lib/*"`. Walking
 * character by character with string awareness is the only safe way.
 */
function stripJsonComments(src) {
    let out = '';
    let i = 0;
    let inString = false;
    while (i < src.length) {
        const c = src[i];
        if (inString) {
            out += c;
            if (c === '\\') {
                out += src[i + 1] ?? '';
                i += 2;
                continue;
            }
            if (c === '"')
                inString = false;
            i++;
            continue;
        }
        if (c === '"') {
            inString = true;
            out += c;
            i++;
            continue;
        }
        if (c === '/' && src[i + 1] === '/') {
            // Line comment — skip to end of line (preserve the newline so
            // downstream line counting still works for error messages).
            const nl = src.indexOf('\n', i);
            if (nl < 0)
                return out;
            i = nl;
            continue;
        }
        if (c === '/' && src[i + 1] === '*') {
            const end = src.indexOf('*/', i + 2);
            if (end < 0)
                return out;
            i = end + 2;
            continue;
        }
        out += c;
        i++;
    }
    return out;
}
function resolveJoin(base, rel) {
    const stack = base.split('/').filter((s) => s !== '' && s !== '.');
    for (const seg of rel.split('/')) {
        if (seg === '' || seg === '.')
            continue;
        if (seg === '..') {
            stack.pop();
            continue;
        }
        stack.push(seg);
    }
    return (base.startsWith('/') ? '/' : '') + stack.join('/');
}
/**
 * Match a resolved import path against component sourceDirs and return
 * the owning component's suggestedName. Picks the LONGEST matching
 * sourceDir prefix (handles the case where one component's dir is
 * nested inside another's). Returns null if no component owns the path.
 */
export function findOwningComponent(resolvedPath, components) {
    let best = null;
    for (const comp of components) {
        const sd = comp.structural?.sourceDir;
        if (!sd)
            continue;
        if (resolvedPath === sd || resolvedPath.startsWith(sd + '/') || resolvedPath.startsWith(sd)) {
            if (!best || sd.length > best.len) {
                best = { name: comp.suggestedName, len: sd.length };
            }
        }
    }
    return best?.name ?? null;
}
/**
 * Build the per-consumer imports graph. Each component's files are
 * walked, their imports parsed, and resolved against the source tree;
 * imports that cross into another component's sourceDir are aggregated.
 */
export async function buildImportsByComponent(opts) {
    const result = {};
    const components = opts.facts.suggestedComponents ?? [];
    if (components.length === 0)
        return result;
    const fileExt = opts.extensionFilter ?? defaultExtFilter;
    const filesByComponent = new Map();
    for (const comp of components) {
        filesByComponent.set(comp.suggestedName, []);
    }
    // Collect all files known to the prepass — candidateMethods + views +
    // entities + routes is sufficient coverage; we don't re-walk the FS.
    const allFiles = new Set();
    for (const cm of opts.facts.candidateMethods ?? [])
        allFiles.add(cm.filePath);
    for (const v of opts.facts.views ?? [])
        allFiles.add(v.filePath);
    for (const e of opts.facts.entities ?? []) {
        if (e.filePath)
            allFiles.add(e.filePath);
    }
    for (const r of opts.facts.routes ?? []) {
        if (r.filePath)
            allFiles.add(r.filePath);
    }
    for (const file of allFiles) {
        if (!fileExt(file))
            continue;
        const owner = findOwningComponent(file, components);
        if (owner)
            filesByComponent.get(owner).push(file);
    }
    // Engines 6.32.7+ — load each component's tsconfig.json paths block
    // upfront so workspace-style aliases (`@shared/ui`, `@/*`) resolve to
    // tree-paths the resolver can match. Most monorepos use these
    // patterns; without alias resolution idle-meta-style codebases
    // produce zero detected cross-component imports.
    //
    // Engines 6.32.8+ — also load each component's package.json `name`
    // field. Workspace imports like `import { X } from '@idle-games/ui'`
    // resolve via npm/yarn workspaces, not tsconfig paths. We build a
    // shared-alias bag keyed by package name → owning component sourceDir
    // and merge it with each component's tsconfig aliases.
    const packageAliases = {};
    for (const comp of components) {
        const sd = comp.structural?.sourceDir;
        if (!sd)
            continue;
        try {
            const pkgJson = await opts.readFile(`${sd}/package.json`);
            if (pkgJson) {
                const parsed = JSON.parse(pkgJson);
                if (parsed?.name) {
                    // Conventional source-root for TS packages — most monorepos
                    // ship from `src/` or `lib/`. Probe a couple of obvious dirs;
                    // if neither exists, fall through to the package root.
                    packageAliases[parsed.name] = sd;
                }
            }
        }
        catch { /* missing or malformed package.json — skip */ }
    }
    const aliasesByComponent = new Map();
    for (const comp of components) {
        const sd = comp.structural?.sourceDir;
        if (!sd)
            continue;
        let tsAliases = {};
        try {
            const tsconfig = await opts.readFile(`${sd}/tsconfig.json`);
            if (tsconfig)
                tsAliases = parseTsconfigPaths(tsconfig, sd);
        }
        catch { /* component without a tsconfig */ }
        // tsconfig aliases override workspace package names when both
        // patterns match (the local tsconfig is the closer authority).
        aliasesByComponent.set(comp.suggestedName, { ...packageAliases, ...tsAliases });
    }
    for (const consumer of components) {
        const files = filesByComponent.get(consumer.suggestedName) ?? [];
        if (files.length === 0)
            continue;
        const aliases = aliasesByComponent.get(consumer.suggestedName) ?? {};
        // Build with Sets for O(1) dedup, then convert to sorted arrays.
        const perTarget = new Map();
        for (const file of files) {
            let source;
            try {
                source = await opts.readFile(file);
            }
            catch {
                continue; // unreadable files don't block the others
            }
            const imports = parseImports(source);
            for (const imp of imports) {
                const resolved = resolveImportPath(file, imp.source, aliases);
                if (!resolved)
                    continue;
                const target = findOwningComponent(resolved, components);
                if (!target || target === consumer.suggestedName)
                    continue;
                if (!perTarget.has(target))
                    perTarget.set(target, new Set());
                const bucket = perTarget.get(target);
                for (const name of imp.names)
                    bucket.add(name);
            }
        }
        if (perTarget.size > 0) {
            const targetMap = {};
            for (const [target, names] of perTarget) {
                targetMap[target] = [...names].sort();
            }
            result[consumer.suggestedName] = targetMap;
        }
    }
    return result;
}
function defaultExtFilter(path) {
    return /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(path);
}
/**
 * Convenience: build the imports graph from a StructuralPrepass backend.
 * The backend's `fileSourceText` provides the file reader; this is the
 * same surface the adapters use to read files.
 */
export async function buildImportsByComponentFromBackend(facts, backend) {
    return buildImportsByComponent({
        facts,
        readFile: (path) => backend.fileSourceText(path),
    });
}
/**
 * V2 (Component Dependencies V2 — 2026-05-08) — extract dotted operation
 * references from a consumer source file. Walks property-access chains
 * rooted at the import-bound symbol names supplied in `bindings` and
 * returns each chain's tail (`charges.create`, `Calculator.calculate`,
 * etc.). The leading symbol is stripped because it names a JS-side
 * binding, not a SpecVerse-side `select:` entry — the dotted op is what
 * matters for V2's `select: [Charge.create]` grammar extension.
 *
 * Heuristic: regex over `<symbol>(.<segment>)+` where each segment is a
 * camelCase or PascalCase identifier. Skips comments via the same naive
 * stripper as `parseImports`. Two-segment chains (`s.charges`) are
 * dropped — those are property accesses, not operation calls. Three-
 * segment-or-more chains (`stripe.charges.create`) keep the trailing two
 * segments (`charges.create`) as the dotted op. Single bound-symbol
 * call sites (`calculator(...)`) emit nothing — there's no operation
 * to name.
 *
 * The recogniser runs per-file and returns a Set so the caller can
 * dedup across files. Empty input → empty Set.
 */
export function detectDottedOps(source, bindings) {
    const out = new Set();
    if (!source)
        return out;
    const cleaned = source
        .replace(/\/\*[\s\S]*?\*\//g, ' ')
        .replace(/(^|[^:])\/\/[^\n]*/g, '$1');
    for (const sym of bindings) {
        if (!sym || !/^[A-Za-z_$][\w$]*$/.test(sym))
            continue;
        // Match sym followed by ≥1 dotted segment. Anchor on a non-word char
        // (or start-of-string) to avoid spuriously matching tail of a longer
        // identifier (`barCharges` should not match a binding `Charges`).
        const re = new RegExp(`(?:^|[^\\w$.])${escapeRegex(sym)}((?:\\.[A-Za-z_$][\\w$]*)+)`, 'g');
        let m;
        while ((m = re.exec(cleaned)) !== null) {
            const tail = m[1].replace(/^\./, ''); // strip leading dot
            const segments = tail.split('.');
            if (segments.length === 0)
                continue;
            if (segments.length === 1) {
                // Single tail segment — `stripe.create` becomes `create` which
                // is too coarse to be useful. Drop unless it looks like a method
                // call (followed by `(` after optional whitespace).
                const after = cleaned.slice(m.index + m[0].length).match(/^\s*\(/);
                if (!after)
                    continue;
                // Even with `(`, drop bare tails — they could be local methods.
                // V2 callers want `Service.op` granularity; bare `op` is the
                // entity-level visibility lane handled by `select: [Service]`.
                continue;
            }
            // Two-or-more tail segments: keep the FINAL two as the dotted op.
            // For `stripe.charges.create` → `charges.create`. For
            // `calculator.calculate` (sym=calculator, tail=calculate) → already
            // handled above. For `service.module.method.deep` → `method.deep`
            // captures the immediate enclosing service + operation.
            const trimmed = segments.slice(-2).join('.');
            // Filter out clearly-non-op tails (numeric, all-caps constants).
            if (!/^[a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*$/.test(trimmed))
                continue;
            out.add(trimmed);
        }
    }
    return out;
}
function escapeRegex(s) {
    return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
 * V2 — build both the bare-names imports graph (existing) AND the V2
 * per-target metadata index (version + dotted ops). The bare-names half
 * is identical to `buildImportsByComponent`; the metadata half walks
 * each target component's package.json (for `version`) and re-scans the
 * consumer's source files for dotted-op patterns rooted at symbols
 * bound by imports from that target.
 *
 * Backward-compat: callers that don't need V2 metadata keep using
 * `buildImportsByComponent` directly. The V2-aware skeleton emitter
 * can switch to this two-output variant when emitting `version:` and
 * dotted-form `select:` entries.
 */
export async function buildImportsByComponentWithMetadata(opts) {
    const components = opts.facts.suggestedComponents ?? [];
    const result = {
        imports: {},
        metadata: {},
    };
    if (components.length === 0)
        return result;
    // Per-target version: read each component's package.json (if any) and
    // record its `version` field. Same fileReader path as the package-name
    // alias loader uses below.
    const versionByTarget = {};
    for (const comp of components) {
        const sd = comp.structural?.sourceDir;
        if (!sd)
            continue;
        try {
            const pkgJson = await opts.readFile(`${sd}/package.json`);
            if (pkgJson) {
                const parsed = JSON.parse(pkgJson);
                if (parsed?.version && typeof parsed.version === 'string') {
                    versionByTarget[comp.suggestedName] = parsed.version;
                }
            }
        }
        catch { /* missing or malformed package.json — skip */ }
    }
    // Reuse the existing bare-names build for symbol aggregation.
    result.imports = await buildImportsByComponent(opts);
    // For each consumer × target, scan the consumer's source files for
    // dotted-op patterns rooted at the imported symbols and record them.
    const fileExt = opts.extensionFilter ?? defaultExtFilter;
    const filesByComponent = new Map();
    for (const comp of components)
        filesByComponent.set(comp.suggestedName, []);
    const allFiles = new Set();
    for (const cm of opts.facts.candidateMethods ?? [])
        allFiles.add(cm.filePath);
    for (const v of opts.facts.views ?? [])
        allFiles.add(v.filePath);
    for (const e of opts.facts.entities ?? []) {
        if (e.filePath)
            allFiles.add(e.filePath);
    }
    for (const r of opts.facts.routes ?? []) {
        if (r.filePath)
            allFiles.add(r.filePath);
    }
    for (const file of allFiles) {
        if (!fileExt(file))
            continue;
        const owner = findOwningComponent(file, components);
        if (owner)
            filesByComponent.get(owner).push(file);
    }
    for (const [consumerName, targetMap] of Object.entries(result.imports)) {
        const targetMetaMap = {};
        const consumerFiles = filesByComponent.get(consumerName) ?? [];
        for (const [targetName, names] of Object.entries(targetMap)) {
            const meta = {};
            if (versionByTarget[targetName]) {
                meta.version = versionByTarget[targetName];
            }
            // Scan consumer files for dotted-op references rooted at any of
            // the bound names from this target. Collect across all files.
            const ops = new Set();
            for (const file of consumerFiles) {
                let source = '';
                try {
                    source = await opts.readFile(file);
                }
                catch {
                    continue;
                }
                const fileOps = detectDottedOps(source, names);
                for (const op of fileOps)
                    ops.add(op);
            }
            if (ops.size > 0) {
                meta.dottedOps = [...ops].sort();
            }
            if (meta.version || meta.dottedOps) {
                targetMetaMap[targetName] = meta;
            }
        }
        if (Object.keys(targetMetaMap).length > 0) {
            result.metadata[consumerName] = targetMetaMap;
        }
    }
    return result;
}
/**
 * Convert an arbitrary identifier-shaped string to a deployment-instance
 * name (lowerCamelCase). `gitnexus` → `gitnexus`; `@stripe/stripe-js` →
 * `stripeStripeJs`; `Bun.spawn` → `bunSpawn`.
 */
function toInstanceKey(raw) {
    if (!raw)
        return 'instance';
    const cleaned = raw
        .replace(/^@/, '')
        .replace(/[^A-Za-z0-9]+/g, ' ')
        .trim();
    if (!cleaned)
        return 'instance';
    const parts = cleaned.split(/\s+/);
    const first = parts[0].charAt(0).toLowerCase() + parts[0].slice(1);
    const rest = parts.slice(1).map((p) => p.charAt(0).toUpperCase() + p.slice(1));
    return [first, ...rest].join('');
}
/** Convert a shell-friendly binary name (kebab-case) into a capability suffix. */
function binaryToCapabilitySuffix(binary) {
    return binary.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
}
/**
 * Stripped + cleaned version of a consumer source for detection passes.
 * Same comment-stripping rule as `parseImports` so `// fetch('foo')` in
 * a comment doesn't trigger a false positive.
 */
function stripCommentsForDetection(source) {
    return source
        .replace(/\/\*[\s\S]*?\*\//g, ' ')
        .replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
/**
 * Detect subprocess invocations:
 *   - `spawnSync('git', [...])`
 *   - `execSync('gitnexus analyze', ...)`
 *   - `child_process.spawn('ffmpeg', ...)`
 *   - `Bun.spawn(['<binary>', ...])`
 *
 * Heuristics:
 *   1. First positional argument is a string literal — treat as the
 *      binary name (`spawnSync('git', [...])` → binary `git`).
 *   2. `Bun.spawn([...])` — first array element string literal is the
 *      binary (`Bun.spawn(['ffmpeg', ...])` → `ffmpeg`).
 *   3. `execSync('foo bar baz')` — command string is shell-style; we
 *      take the first whitespace-delimited token as the binary
 *      (`execSync('git status')` → `git`).
 *   4. Variable arguments (`spawnSync(cmd, args)`) — skipped, can't
 *      reliably name the binary.
 *
 * Multiple invocations of the same binary in the same source produce
 * one hint (deduped by binary name).
 *
 * Each hint emits:
 *   - `import:` entry with `from: <binary>` (kebab-cased)
 *   - deployment instance under `infrastructure:`, `advertises:
 *     ["executable.<binary>"]`, `config: { binary: <binary> }`
 */
export function detectSubprocessCalls(source, sourceFile) {
    if (!source)
        return [];
    const cleaned = stripCommentsForDetection(source);
    const seen = new Map();
    // Pattern A: spawnSync / execSync / spawn / exec — first arg is a
    // string literal binary name OR a shell command string.
    // We accept the function called as a bare identifier OR as a method
    // on `child_process` / `cp` / similar (`childProcess.spawn(...)`).
    const fnRe = /(?:^|[^.\w$])(?:[a-zA-Z_$][\w$]*\.)?(spawn(?:Sync)?|exec(?:Sync|File|FileSync)?)\s*\(\s*['"]([^'"]+)['"]/g;
    let m;
    while ((m = fnRe.exec(cleaned)) !== null) {
        const fn = m[1];
        const argRaw = m[2];
        let binary;
        if (fn.startsWith('exec') && !fn.startsWith('execFile')) {
            // `exec('git status')` — shell command string. Take the first token.
            const firstToken = argRaw.trim().split(/\s+/)[0];
            // Strip path prefix (`/usr/bin/git` → `git`).
            binary = firstToken.split('/').pop();
        }
        else {
            // `spawn('git', [...])` / `execFile('git', [...])` — first arg is
            // the binary directly.
            binary = argRaw.split('/').pop();
        }
        if (!binary || !/^[a-zA-Z][\w.\-]*$/.test(binary))
            continue;
        if (seen.has(binary))
            continue;
        seen.set(binary, makeSubprocessHint(binary, sourceFile));
    }
    // Pattern B: Bun.spawn(['ffmpeg', ...]) — bun-specific, array form.
    const bunRe = /Bun\.spawn\s*\(\s*\[\s*['"]([^'"]+)['"]/g;
    while ((m = bunRe.exec(cleaned)) !== null) {
        const binary = m[1].split('/').pop();
        if (!binary || !/^[a-zA-Z][\w.\-]*$/.test(binary))
            continue;
        if (seen.has(binary))
            continue;
        seen.set(binary, makeSubprocessHint(binary, sourceFile));
    }
    return [...seen.values()];
}
function makeSubprocessHint(binary, sourceFile) {
    const capability = `executable.${binaryToCapabilitySuffix(binary)}`;
    return {
        from: binary,
        pattern: 'subprocess',
        ...(sourceFile ? { sourceFile } : {}),
        deploymentHint: {
            category: 'infrastructure',
            instanceName: toInstanceKey(binary),
            advertises: [capability],
            config: { binary },
        },
    };
}
/**
 * Detect HTTP/RPC service-client construction:
 *   - `axios.create({ baseURL: '...' })` — REST client
 *   - `axios.create({ baseURL: process.env.X })` — env-driven REST client
 *   - `fetch(BASE_URL + ...)` / `fetch(\`${BASE}/foo\`)` — bare fetch
 *   - `new GreeterClient('host:port', ...)` — gRPC stub instantiation
 *   - `new Client({ url: ... })` — generic MCP / RPC client
 *
 * Heuristics:
 *   1. axios.create({ baseURL }) — extracts the baseURL string when literal,
 *      or the env-var name when the value is `process.env.<X>`.
 *   2. Bare `fetch(<expr>)` — only when called against an env-var-shaped
 *      identifier (`process.env.X` or an UPPER_CASE constant the consumer
 *      owns); otherwise too noisy. The env-var becomes the hint's
 *      `endpoint.env`.
 *   3. gRPC stubs are tagged by the `*Client` suffix on a constructor
 *      whose first arg is a hostname-shaped string ('host:port').
 *
 * Returns an empty array when no patterns match. Multiple distinct
 * baseURLs in one file produce multiple hints.
 *
 * Each hint emits:
 *   - `import:` entry with `from: <baseURL-host or env-var>`
 *   - deployment instance under `services:`, `advertises:
 *     ["api.rest"]` (or `api.grpc` / `api.rpc`), `config:
 *     { endpoint: { env: <ENV_NAME> | url: <URL> } }`
 */
export function detectServiceClients(source, sourceFile) {
    if (!source)
        return [];
    const cleaned = stripCommentsForDetection(source);
    const out = [];
    const seen = new Set();
    // axios.create({ baseURL: ... }) — both literal and env-var.
    // Match the call up to the closing brace of the options object; we
    // don't need a full parser, just a non-greedy scan.
    const axiosRe = /(?:axios|httpClient|http)\s*\.\s*create\s*\(\s*\{([^}]*)\}/g;
    let m;
    while ((m = axiosRe.exec(cleaned)) !== null) {
        const optsBody = m[1];
        const literalMatch = optsBody.match(/baseURL\s*:\s*['"`]([^'"`]+)['"`]/);
        const envMatch = optsBody.match(/baseURL\s*:\s*process\.env\.([A-Z][A-Z0-9_]*)/);
        if (literalMatch) {
            const url = literalMatch[1];
            const host = extractHost(url);
            if (!host)
                continue;
            const key = `axios:${host}`;
            if (seen.has(key))
                continue;
            seen.add(key);
            out.push(makeServiceClientHint({
                from: host,
                protocol: 'rest',
                endpointUrl: url,
                sourceFile,
            }));
        }
        else if (envMatch) {
            const env = envMatch[1];
            const key = `axios-env:${env}`;
            if (seen.has(key))
                continue;
            seen.add(key);
            out.push(makeServiceClientHint({
                from: envToProviderName(env),
                protocol: 'rest',
                endpointEnv: env,
                sourceFile,
            }));
        }
    }
    // Bare fetch(process.env.X + ...) or fetch(`${process.env.X}/...`).
    // Restricted form to avoid false positives against in-tree fetch calls.
    const fetchEnvRe = /\bfetch\s*\(\s*(?:`[^`]*\$\{)?process\.env\.([A-Z][A-Z0-9_]*)/g;
    while ((m = fetchEnvRe.exec(cleaned)) !== null) {
        const env = m[1];
        const key = `fetch-env:${env}`;
        if (seen.has(key))
            continue;
        seen.add(key);
        out.push(makeServiceClientHint({
            from: envToProviderName(env),
            protocol: 'rest',
            endpointEnv: env,
            sourceFile,
        }));
    }
    // gRPC stub: `new <Pascal>Client('host:port', ...)`. Looks for the
    // canonical grpc-js / grpc-web convention.
    const grpcRe = /new\s+([A-Z][\w$]*Client)\s*\(\s*['"]([\w.\-]+:\d+)['"]/g;
    while ((m = grpcRe.exec(cleaned)) !== null) {
        const stub = m[1];
        const target = m[2];
        const key = `grpc:${stub}`;
        if (seen.has(key))
            continue;
        seen.add(key);
        const providerName = stub.replace(/Client$/, '');
        out.push(makeServiceClientHint({
            from: providerName,
            protocol: 'grpc',
            endpointUrl: target,
            sourceFile,
        }));
    }
    return out;
}
function makeServiceClientHint(opts) {
    const advertise = `api.${opts.protocol}`;
    const endpoint = {};
    if (opts.endpointEnv)
        endpoint.env = opts.endpointEnv;
    if (opts.endpointUrl)
        endpoint.url = opts.endpointUrl;
    return {
        from: opts.from,
        pattern: 'service-client',
        ...(opts.sourceFile ? { sourceFile: opts.sourceFile } : {}),
        deploymentHint: {
            category: 'services',
            instanceName: toInstanceKey(opts.from),
            advertises: [advertise],
            config: { endpoint },
        },
    };
}
/**
 * Best-effort hostname extraction. Drops protocol + path; returns the
 * host portion (which may include a port). Returns null for clearly-
 * malformed URLs. The host doubles as a `from:` value for the import.
 */
function extractHost(url) {
    // Drop protocol.
    const protoStrip = url.replace(/^[a-z][a-zA-Z0-9+.-]*:\/\//, '');
    // First path/query segment.
    const host = protoStrip.split(/[/?#]/)[0];
    if (!host || /\$\{|\$/.test(host))
        return null; // unresolved interpolation
    return host;
}
/**
 * Map `XXX_API_URL` / `STRIPE_KEY` style env-var names to a provider
 * name suitable for `from:`. Drops the trailing _URL / _KEY / _BASE_URL
 * suffix and lowercases. Falls back to lowercased env-var when no
 * suffix matches.
 */
function envToProviderName(env) {
    const stripped = env.replace(/_(API_URL|BASE_URL|URL|KEY|TOKEN|SECRET|HOST|ENDPOINT)$/i, '');
    return stripped.toLowerCase().replace(/_/g, '-');
}
/**
 * Detect async messaging crossings:
 *   - `rabbit.subscribe('queue', handler)` / `rabbitMq.publish(...)`
 *   - `kafka.consume(...)` / `kafka.subscribe(...)` / `kafkaProducer.send(...)`
 *   - `eventBus.publish(...)` / `eventBus.subscribe(...)` (in-memory or
 *      cross-component bus crossings)
 *   - `nats.subscribe(...)` / `nats.publish(...)`
 *   - `redis.xadd(...)` (streams) / `redis.publish(...)` (pub-sub)
 *
 * Heuristic:
 *   - Match `<broker-handle>.<verb>(...)` where verb ∈ {subscribe,
 *     consume, publish, send, xadd}.
 *   - Broker name comes from the receiver identifier (`rabbitMq`,
 *     `kafka`, `eventBus`).
 *   - First positional arg, if a string literal, is recorded as a
 *     `queues[]` / `topics[]` entry on the deployment hint.
 *
 * Each hint emits:
 *   - `import:` entry with `from: <broker>` (lowercased)
 *   - deployment instance under `communications:`, `advertises:
 *     ["messaging.<broker>"]`, `config: { broker, queues: [...] }`
 */
export function detectMessagingCrossings(source, sourceFile) {
    if (!source)
        return [];
    const cleaned = stripCommentsForDetection(source);
    const byBroker = new Map();
    // Receiver names we recognise as brokers. Conservative list to keep
    // false-positive rate low; arbitrary `*.publish()` would over-match.
    const BROKER_RE = /\b(rabbit|rabbitmq|rabbitMq|kafka|kafkaProducer|kafkaConsumer|nats|redis|eventBus|amqp|sns|sqs|sqsClient|snsClient)\b/;
    const verbsRe = /\b(subscribe|consume|publish|send|xadd|sendMessage|receiveMessage)\s*\(/;
    // Combined: `<broker>.<verb>('queueOrTopic', ...)` — first arg literal.
    const callRe = /\b([a-zA-Z_$][\w$]*)\s*\.\s*(subscribe|consume|publish|send|xadd|sendMessage|receiveMessage)\s*\(\s*['"]([^'"]+)['"]/g;
    let m;
    while ((m = callRe.exec(cleaned)) !== null) {
        const receiver = m[1];
        const queue = m[3];
        if (!BROKER_RE.test(receiver))
            continue;
        const broker = receiverToBrokerName(receiver);
        if (!byBroker.has(broker)) {
            byBroker.set(broker, { queues: new Set(), broker });
        }
        byBroker.get(broker).queues.add(queue);
    }
    // Also catch `<broker>.<verb>(<non-string-arg>)` so the broker still
    // shows up as a hint, just with no queues recorded.
    const argLessRe = /\b([a-zA-Z_$][\w$]*)\s*\.\s*(subscribe|consume|publish|send|xadd|sendMessage|receiveMessage)\s*\(/g;
    while ((m = argLessRe.exec(cleaned)) !== null) {
        const receiver = m[1];
        if (!BROKER_RE.test(receiver))
            continue;
        if (!verbsRe.test(m[0]))
            continue;
        const broker = receiverToBrokerName(receiver);
        if (!byBroker.has(broker)) {
            byBroker.set(broker, { queues: new Set(), broker });
        }
        // No queue to add.
    }
    const out = [];
    for (const { broker, queues } of byBroker.values()) {
        out.push(makeMessagingHint(broker, [...queues].sort(), sourceFile));
    }
    return out;
}
/** Normalise a JS-side broker handle to a canonical broker name. */
function receiverToBrokerName(receiver) {
    const lower = receiver.toLowerCase();
    if (lower.startsWith('rabbit'))
        return 'rabbitmq';
    if (lower.startsWith('kafka'))
        return 'kafka';
    if (lower.startsWith('nats'))
        return 'nats';
    if (lower.startsWith('redis'))
        return 'redis';
    if (lower.startsWith('amqp'))
        return 'amqp';
    if (lower.includes('sqs'))
        return 'sqs';
    if (lower.includes('sns'))
        return 'sns';
    if (lower === 'eventbus')
        return 'eventbus';
    return lower;
}
function makeMessagingHint(broker, queues, sourceFile) {
    const config = { broker };
    if (queues.length > 0)
        config.queues = queues;
    // Capability tag: messaging.<broker>. The `<topic-domain>` form in the
    // proposal would require domain inference from queue names — not done
    // here; spec author tunes if needed.
    const advertise = `messaging.${broker}`;
    return {
        from: broker,
        pattern: 'messaging',
        ...(sourceFile ? { sourceFile } : {}),
        deploymentHint: {
            category: 'communications',
            instanceName: toInstanceKey(broker),
            advertises: [advertise],
            config,
        },
    };
}
/**
 * Detect known managed-SaaS SDK initialisations:
 *   - `new Stripe(apiKey, ...)` → from: `@stripe/stripe-js`, advertises:
 *     `managed.payments`
 *   - `new OpenAI({ apiKey })` → from: `openai`, advertises: `managed.ai`
 *   - `new Anthropic({ apiKey })` → from: `@anthropic-ai/sdk`, advertises:
 *     `managed.ai`
 *   - `new SESClient({ region })` / AWS SDK init → from: `@aws-sdk/<x>`
 *   - `Twilio(sid, token)` → from: `twilio`, advertises: `managed.sms`
 *   - `new SendGridClient({ apiKey })` / `sgMail.setApiKey(...)` →
 *     from: `@sendgrid/mail`, advertises: `managed.email`
 *
 * The lookup table is INTENTIONALLY conservative — only well-known SDKs
 * with stable name signatures. For unknown providers, the spec author
 * adds the `import:` block manually.
 *
 * Authentication env-var inference:
 *   - When the constructor is called with `process.env.X`, that's the
 *     authEnv.
 *   - Otherwise we leave `authEnv` unset; the deployment-instance config
 *     just carries `provider:` and the spec author fills the env wiring.
 *
 * Each hint emits:
 *   - `import:` entry with `from: <scoped-package-name>` and version
 *     when the consumer's package.json names it as a dependency.
 *   - deployment instance under `infrastructure:`, `advertises:
 *     ["managed.<domain>"]`, `config: { provider: <name>, authEnv: <ENV> }`
 */
export function detectManagedSdkInit(source, sourceFile) {
    if (!source)
        return [];
    const cleaned = stripCommentsForDetection(source);
    const out = [];
    const seen = new Set();
    // Known-SDK lookup. Keys are constructor identifiers (`Stripe`,
    // `OpenAI`, etc.); values describe the canonical scoped package name
    // and capability tag.
    const SDK_TABLE = {
        Stripe: { pkg: '@stripe/stripe-js', provider: 'stripe', advertise: 'managed.payments' },
        OpenAI: { pkg: 'openai', provider: 'openai', advertise: 'managed.ai' },
        Anthropic: { pkg: '@anthropic-ai/sdk', provider: 'anthropic', advertise: 'managed.ai' },
        Twilio: { pkg: 'twilio', provider: 'twilio', advertise: 'managed.sms' },
        SendGridClient: { pkg: '@sendgrid/client', provider: 'sendgrid', advertise: 'managed.email' },
        SESClient: { pkg: '@aws-sdk/client-ses', provider: 'aws-ses', advertise: 'managed.email' },
        SNSClient: { pkg: '@aws-sdk/client-sns', provider: 'aws-sns', advertise: 'managed.sns' },
        SQSClient: { pkg: '@aws-sdk/client-sqs', provider: 'aws-sqs', advertise: 'managed.queue' },
        S3Client: { pkg: '@aws-sdk/client-s3', provider: 'aws-s3', advertise: 'managed.storage' },
        DynamoDBClient: { pkg: '@aws-sdk/client-dynamodb', provider: 'aws-dynamodb', advertise: 'managed.database' },
        PrismaClient: { pkg: '@prisma/client', provider: 'prisma', advertise: 'managed.database' },
    };
    // Match `new <Ctor>(...)` for each known constructor. Capture the
    // first-arg form so we can pull out `process.env.X` if present.
    for (const [ctor, info] of Object.entries(SDK_TABLE)) {
        const re = new RegExp(`new\\s+${escapeRegex(ctor)}\\s*\\(([^)]*)\\)`, 'g');
        let m;
        while ((m = re.exec(cleaned)) !== null) {
            const args = m[1] ?? '';
            const envMatch = args.match(/process\.env\.([A-Z][A-Z0-9_]*)/);
            const key = `${info.pkg}:${envMatch ? envMatch[1] : 'noenv'}`;
            if (seen.has(key))
                continue;
            seen.add(key);
            const config = { provider: info.provider };
            if (envMatch)
                config.authEnv = envMatch[1];
            out.push({
                from: info.pkg,
                pattern: 'managed-sdk',
                ...(sourceFile ? { sourceFile } : {}),
                deploymentHint: {
                    category: 'infrastructure',
                    instanceName: toInstanceKey(info.provider),
                    advertises: [info.advertise],
                    config,
                },
            });
        }
    }
    // Also catch the bare-call shape Twilio uses (`Twilio(sid, token)`).
    const twilioRe = /(?:^|[^.\w$])Twilio\s*\(\s*([^)]*)\)/g;
    let tm;
    while ((tm = twilioRe.exec(cleaned)) !== null) {
        const args = tm[1] ?? '';
        const envMatch = args.match(/process\.env\.([A-Z][A-Z0-9_]*)/);
        const key = `twilio:${envMatch ? envMatch[1] : 'noenv'}`;
        if (seen.has(key))
            continue;
        seen.add(key);
        const config = { provider: 'twilio' };
        if (envMatch)
            config.authEnv = envMatch[1];
        out.push({
            from: 'twilio',
            pattern: 'managed-sdk',
            ...(sourceFile ? { sourceFile } : {}),
            deploymentHint: {
                category: 'infrastructure',
                instanceName: toInstanceKey('twilio'),
                advertises: ['managed.sms'],
                config,
            },
        });
    }
    return out;
}
/**
 * Run all four V2 Phase 2 detections against a single source file and
 * return a merged hint list. Convenience wrapper used by the prepass
 * orchestrator.
 */
export function detectNonLibraryDeps(source, sourceFile) {
    return [
        ...detectSubprocessCalls(source, sourceFile),
        ...detectServiceClients(source, sourceFile),
        ...detectMessagingCrossings(source, sourceFile),
        ...detectManagedSdkInit(source, sourceFile),
    ];
}
/**
 * Build the per-component non-library imports index. Walks every
 * consumer component's source files, runs the four detection passes,
 * deduplicates by (from, pattern) within a component, and unions the
 * select / queues fields. File walking reuses the same file-discovery
 * logic as `buildImportsByComponent` so detections are scoped to the
 * same surface as in-tree imports.
 */
export async function buildNonLibraryImportsByComponent(opts) {
    const result = {};
    const components = opts.facts.suggestedComponents ?? [];
    if (components.length === 0)
        return result;
    const fileExt = opts.extensionFilter ?? defaultExtFilter;
    const filesByComponent = new Map();
    for (const comp of components)
        filesByComponent.set(comp.suggestedName, []);
    const allFiles = new Set();
    for (const cm of opts.facts.candidateMethods ?? [])
        allFiles.add(cm.filePath);
    for (const v of opts.facts.views ?? [])
        allFiles.add(v.filePath);
    for (const e of opts.facts.entities ?? []) {
        if (e.filePath)
            allFiles.add(e.filePath);
    }
    for (const r of opts.facts.routes ?? []) {
        if (r.filePath)
            allFiles.add(r.filePath);
    }
    for (const file of allFiles) {
        if (!fileExt(file))
            continue;
        const owner = findOwningComponent(file, components);
        if (owner)
            filesByComponent.get(owner).push(file);
    }
    for (const consumer of components) {
        const files = filesByComponent.get(consumer.suggestedName) ?? [];
        if (files.length === 0)
            continue;
        // Merge per (from, pattern) tuple so multiple subprocess calls or
        // multiple managed-SDK constructors from the same provider collapse.
        const merged = new Map();
        for (const file of files) {
            let source = '';
            try {
                source = await opts.readFile(file);
            }
            catch {
                continue;
            }
            const hints = detectNonLibraryDeps(source, file);
            for (const hint of hints) {
                const key = `${hint.pattern}:${hint.from}`;
                const existing = merged.get(key);
                if (!existing) {
                    merged.set(key, hint);
                    continue;
                }
                // Union select arrays.
                if (hint.select) {
                    const sel = new Set([...(existing.select ?? []), ...hint.select]);
                    existing.select = [...sel].sort();
                }
                // Union queues.
                const exQ = existing.deploymentHint.config.queues;
                const newQ = hint.deploymentHint.config.queues;
                if (newQ && newQ.length > 0) {
                    const unioned = new Set([...(exQ ?? []), ...newQ]);
                    existing.deploymentHint.config.queues = [...unioned].sort();
                }
                // Prefer an existing authEnv; otherwise inherit a newly-detected one.
                const exAuth = existing.deploymentHint.config.authEnv;
                const newAuth = hint.deploymentHint.config.authEnv;
                if (!exAuth && newAuth) {
                    existing.deploymentHint.config.authEnv = newAuth;
                }
            }
        }
        if (merged.size > 0) {
            // Stable order: sort by pattern then `from:`.
            const arr = [...merged.values()].sort((a, b) => {
                if (a.pattern !== b.pattern)
                    return a.pattern.localeCompare(b.pattern);
                return a.from.localeCompare(b.from);
            });
            result[consumer.suggestedName] = arr;
        }
    }
    return result;
}
/**
 * Convenience: run the V2 Phase 2 non-library walker against a
 * StructuralPrepass backend (analogous to
 * `buildImportsByComponentFromBackend`).
 */
export async function buildNonLibraryImportsByComponentFromBackend(facts, backend) {
    return buildNonLibraryImportsByComponent({
        facts,
        readFile: (path) => backend.fileSourceText(path),
    });
}
//# sourceMappingURL=imports-graph.js.map