All files discover.ts

100% Statements 67/67
95.45% Branches 21/22
100% Functions 5/5
100% Lines 67/67

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 1291x                                                       1x 1x 1x 1x 1x 1x 1x 1x 1x 1x   1x 38x 38x 38x 38x 38x   38x 38x 38x   38x 38x 38x 38x 38x 38x 38x   38x 34x 4x 38x 38x                         38x 38x 38x 38x 38x 2x 2x 2x 36x 38x 35x 38x 34x 34x                       38x 38x 38x 38x 38x 38x 38x 38x 38x 38x 38x 38x 49x               49x 49x 49x 49x 49x 38x 38x 38x  
/**
 * Python file discovery — Stage 0 for the Python adapter.
 *
 * Lands in PR 5 of plan docs/plans/10-graph-language-pluggability.md.
 *
 * Strategy:
 *   1. If `pyproject.toml` is present, use it as the language config
 *      anchor for cacheKey. The PR 5 implementation does NOT parse
 *      `[tool.opensip-graph].include`; that's a future enhancement
 *      flagged in plan 10 §8 Q4. We just record the file path.
 *   2. If `setup.py` is present (and no pyproject.toml), use it as the
 *      anchor instead.
 *   3. Walk the project tree collecting all `.py` files, excluding
 *      common non-source directories (`.venv`, `venv`, `__pycache__`,
 *      `.tox`, `node_modules`, `dist`, `build`, `.eggs`).
 *
 * Returns absolute, realpath-normalized, sorted, deduped paths so I-9
 * (referential transparency of discoverFiles) holds across runs.
 */
 
import { existsSync, realpathSync } from 'node:fs';
import { resolve, sep } from 'node:path';
 
import { logger } from '@opensip-tools/core';
import { glob } from 'glob';
 
import type { DiscoverInput, DiscoverOutput } from '@opensip-tools/graph';
 
const EXCLUDED_DIR_GLOBS: readonly string[] = [
  '**/.venv/**',
  '**/venv/**',
  '**/__pycache__/**',
  '**/.tox/**',
  '**/node_modules/**',
  '**/dist/**',
  '**/build/**',
  '**/.eggs/**',
];
 
export function discoverFiles(input: DiscoverInput): DiscoverOutput {
  logger.info({
    evt: 'graph.discover.start',
    module: 'graph:discover:python',
    projectDir: input.cwd,
  });
 
  const projectDirAbs = normalizeProjectDir(input.cwd);
  const configPathAbs = resolveConfigPath(projectDirAbs, input.configPathOverride);
  const files = collectPythonFiles(projectDirAbs);
 
  logger.info({
    evt: 'graph.discover.complete',
    module: 'graph:discover:python',
    projectDir: projectDirAbs,
    configPath: configPathAbs ?? '(none)',
    fileCount: files.length,
  });
 
  const out: DiscoverOutput = configPathAbs === undefined
    ? { projectDirAbs, files }
    : { projectDirAbs, files, configPathAbs };
  return out;
}
 
/* v8 ignore start */
function normalizeProjectDir(projectDir: string): string {
  const abs = resolve(projectDir);
  try {
    return realpathSync(abs);
  } catch {
    return abs;
  }
}
/* v8 ignore stop */
 
function resolveConfigPath(
  projectDirAbs: string,
  override: string | undefined,
): string | undefined {
  if (override !== undefined && override.length > 0) {
    const abs = resolve(projectDirAbs, override);
    return existsSync(abs) ? realpathOrPath(abs) : abs;
  }
  const pyproject = resolve(projectDirAbs, 'pyproject.toml');
  if (existsSync(pyproject)) return realpathOrPath(pyproject);
  const setupPy = resolve(projectDirAbs, 'setup.py');
  if (existsSync(setupPy)) return realpathOrPath(setupPy);
  return undefined;
}
 
/* v8 ignore start */
function realpathOrPath(p: string): string {
  try {
    return realpathSync(p);
  } catch {
    return p;
  }
}
/* v8 ignore stop */
 
function collectPythonFiles(projectDirAbs: string): readonly string[] {
  const matches: string[] = glob.sync('**/*.py', {
    cwd: projectDirAbs,
    absolute: true,
    ignore: [...EXCLUDED_DIR_GLOBS],
    nodir: true,
    follow: false,
    dot: false,
  });
  const seen = new Set<string>();
  const out: string[] = [];
  for (const m of matches) {
    let real: string = m;
    /* v8 ignore start */
    try {
      real = realpathSync(m);
    } catch {
      // fall through with original
    }
    /* v8 ignore stop */
    const key = real.split(sep).join('/');
    if (seen.has(key)) continue;
    seen.add(key);
    out.push(real);
  }
  out.sort();
  return out;
}