All files discover.ts

91.11% Statements 41/45
55% Branches 11/20
100% Functions 8/8
93.1% Lines 27/29

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 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151                                                                                              2x 2x 2x   2x 2x           2x 2x         2x   2x               2x     2x                                       2x 1x     2x 4x 4x                                       2x               2x 2x 2x 4x               4x 4x 4x 4x   2x 2x    
// @fitness-ignore-file error-handling-quality -- realpathSync probe for symlink dedup; exception → fall through with the original path (file might be in a symlinked dir or have been unlinked), already marked v8-ignore as effectively unreachable on real input.
// @fitness-ignore-file batch-operation-limits -- iterates bounded collection (source directories within a single project root)
/**
 * Shared file-discovery scaffolding for the tree-sitter graph adapters.
 *
 * The discover template is byte-identical across graph-go / graph-java /
 * graph-python / graph-rust save four data inputs:
 *
 *   - `extension`         — the source-file extension to glob (`.go`, …).
 *   - `excludedDirGlobs`  — vendored / build-output / VCS dirs to skip.
 *   - `configCandidates`  — the ordered config-file precedence list
 *                           (resolved-deps first, e.g. `['go.sum','go.mod']`).
 *   - `languageId`        — the `graph:discover:<id>` log tag.
 *
 * `createDiscover` closes over those and returns the adapter's
 * `discoverFiles(input): DiscoverOutput`. The collect loop, the symlink
 * realpath/dedup/sort normalization (so I-9 referential transparency
 * holds), and the `DiscoverOutput` assembly are shared verbatim.
 */
 
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';
 
/** Per-language inputs to the shared discover template. */
export interface TreeSitterDiscoverConfig {
  /** Source-file extension WITHOUT the leading dot, e.g. `'go'`, `'py'`. */
  readonly extension: string;
  /** Directory globs to exclude from the recursive source-file walk. */
  readonly excludedDirGlobs: readonly string[];
  /**
   * Ordered config-file precedence list (resolved-deps first). The first
   * candidate that exists at the project root becomes the cacheKey anchor.
   */
  readonly configCandidates: readonly string[];
  /** Log-tag suffix for `graph:discover:<languageId>`. */
  readonly languageId: string;
}
 
/** Builds the adapter's `discoverFiles` from per-language config. */
export function createDiscover(
  config: TreeSitterDiscoverConfig,
): (input: DiscoverInput) => DiscoverOutput {
  const { extension, excludedDirGlobs, configCandidates, languageId } = config;
  const module = `graph:discover:${languageId}`;
  const pattern = `**/*.${extension}`;
 
  return function discoverFiles(input: DiscoverInput): DiscoverOutput {
    logger.info({
      evt: 'graph.discover.start',
      module,
      projectDir: input.cwd,
    });
 
    const projectDirAbs = normalizeProjectDir(input.cwd);
    const configPathAbs = resolveConfigPath(
      projectDirAbs,
      input.configPathOverride,
      configCandidates,
    );
    const files = collectFiles(projectDirAbs, pattern, excludedDirGlobs);
 
    logger.info({
      evt: 'graph.discover.complete',
      module,
      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,
  configCandidates: readonly string[],
): string | undefined {
  Iif (override !== undefined && override.length > 0) {
    const abs = resolve(projectDirAbs, override);
    return existsSync(abs) ? realpathOrPath(abs) : abs;
  }
  for (const candidate of configCandidates) {
    const path = resolve(projectDirAbs, candidate);
    if (existsSync(path)) return realpathOrPath(path);
  }
  return undefined;
}
 
/* v8 ignore start */
function realpathOrPath(p: string): string {
  try {
    return realpathSync(p);
  } catch {
    return p;
  }
}
/* v8 ignore stop */
 
function collectFiles(
  projectDirAbs: string,
  pattern: string,
  excludedDirGlobs: readonly string[],
): readonly string[] {
  const matches: string[] = glob.sync(pattern, {
    cwd: projectDirAbs,
    absolute: true,
    ignore: [...excludedDirGlobs],
    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('/');
    Iif (seen.has(key)) continue;
    seen.add(key);
    out.push(real);
  }
  out.sort();
  return out;
}