All files cache-key.ts

96.29% Statements 26/27
84.61% Branches 11/13
100% Functions 3/3
96.29% Lines 26/27

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 641x                                                         1x   1x 7x 7x 7x   7x     7x 7x 2x 2x 7x 1x 1x 4x 4x 4x 7x       4x 7x 7x 7x 7x   1x   1x 1x  
/**
 * Python cacheKey implementation.
 *
 * Produces `py-${pythonVersion}-${pyprojectContentHash || 'no-config'}`.
 *
 * The "Python version" is best-effort: we look for a `requires-python`
 * line in `pyproject.toml` (PEP 621) — this is a string like
 * `>=3.10,<4.0` — and emit it verbatim. If we can't find one we fall
 * back to the literal `unknown`. This is a CACHE INVALIDATION key, not
 * a source-of-truth — its only job is to flip when the toolchain
 * intent changes.
 *
 * Per contract invariant I-6 (cacheKey is stable for stable input AND
 * changes when the language config changes): the function is purely a
 * function of `(pyproject content)`. Two calls without any pyproject
 * file produce the same `py-unknown-no-config` key.
 *
 * Per I-8 (different adapter prefixes): we emit `py-`, distinct from
 * the TypeScript adapter's `ts-` and the Rust adapter's `rs-`.
 */
 
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
 
import type { CacheKeyInput } from '@opensip-tools/graph';
 
// Anchored to start-of-line; horizontal whitespace ([\t ]) and the
// inner `[^"'\n]` keep matching linear. Using `\s` would cross
// newlines and let pathological inputs explore O(n^2) prefixes.
const REQUIRES_PYTHON_RE = /^[\t ]*requires-python[\t ]*=[\t ]*["']([^"'\n]+)["']/m;
 
export function cacheKey(input: CacheKeyInput): string {
  const { pythonVersion, configHash } = readConfig(input.configPathAbs);
  return `py-${pythonVersion}-${configHash}`;
}
 
function readConfig(configPathAbs: string | undefined): {
  readonly pythonVersion: string;
  readonly configHash: string;
} {
  if (configPathAbs === undefined || configPathAbs.length === 0) {
    return { pythonVersion: 'unknown', configHash: 'no-config' };
  }
  if (!existsSync(configPathAbs)) {
    return { pythonVersion: 'unknown', configHash: `missing:${configPathAbs}` };
  }
  let content: string;
  try {
    content = readFileSync(configPathAbs, 'utf8');
  } catch {
    /* v8 ignore next */
    return { pythonVersion: 'unknown', configHash: `unreadable:${configPathAbs}` };
  }
  const match = REQUIRES_PYTHON_RE.exec(content);
  const pythonVersion = match ? sanitize(match[1] ?? 'unknown') : 'unknown';
  const configHash = createHash('sha256').update(content).digest('hex').slice(0, 16);
  return { pythonVersion, configHash };
}
 
function sanitize(s: string): string {
  // Keep cache-key strings shell- and filename-safe.
  return s.replaceAll(/[^A-Za-z0-9._+-]/g, '_').slice(0, 32);
}