Ctrl K
ComunidadGuías…

scripts/published-cli-contract.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { execFileSync } from 'node:child_process'
import { compare, valid as isValidSemver } from 'semver'

export const PUBLISHED_CLI_TAG_PREFIX = 'cli-'
export const PUBLISHED_CLI_TAG_PATTERN = /^cli-[0-9]/
export const PUBLISHED_CLI_RPC_PATTERN = /\.rpc\(\s*['"`]([a-z][a-z0-9_]*)['"`]/g

export interface PublishedCliRpcCall {
  name: string
  /** null when the RPC argument expression cannot be resolved statically (e.g. a variable). */
  argKeys: string[] | null
}

export type GitRunner = (args: string[]) => string

function defaultGitRunner(args: string[]): string {
  return execFileSync('git', args, { encoding: 'utf8' }).trim()
}

export function comparePublishedCliTags(left: string, right: string): number {
  const leftVersion = left.slice(PUBLISHED_CLI_TAG_PREFIX.length)
  const rightVersion = right.slice(PUBLISHED_CLI_TAG_PREFIX.length)

  if (isValidSemver(leftVersion) && isValidSemver(rightVersion))
    return compare(leftVersion, rightVersion)

  const parse = (tag: string) => tag.replace(PUBLISHED_CLI_TAG_PREFIX, '').split(/[.-]/).map(part => Number.parseInt(part, 10) || 0)
  const leftParts = parse(left)
  const rightParts = parse(right)
  const length = Math.max(leftParts.length, rightParts.length)

  for (let index = 0; index < length; index++) {
    const delta = (leftParts[index] ?? 0) - (rightParts[index] ?? 0)
    if (delta !== 0)
      return delta
  }

  return 0
}

function listPublishedCliTags(runGit: GitRunner): string[] {
  const output = runGit(['tag', '-l', `${PUBLISHED_CLI_TAG_PREFIX}*`])
  const tags = output
    .split('\n')
    .map(tag => tag.trim())
    .filter(tag => PUBLISHED_CLI_TAG_PATTERN.test(tag))

  if (tags.length === 0)
    throw new Error(`No published CLI tags found (expected ${PUBLISHED_CLI_TAG_PREFIX}<semver>)`)

  return tags.sort(comparePublishedCliTags)
}

export function resolveLatestPublishedCliTag(runGit: GitRunner = defaultGitRunner): string {
  return listPublishedCliTags(runGit).at(-1)!
}

export function resolvePublishedCliNpmVersion(tag: string): string {
  if (!PUBLISHED_CLI_TAG_PATTERN.test(tag))
    throw new Error(`Expected a published CLI tag, got ${tag}`)

  return tag.slice(PUBLISHED_CLI_TAG_PREFIX.length)
}

type NpmRunner = (args: string[]) => string

function defaultNpmRunner(args: string[]): string {
  return execFileSync('npm', args, { encoding: 'utf8' }).trim()
}

export function normalizePublishedCliNpmVersions(parsed: unknown): string[] {
  if (Array.isArray(parsed))
    return parsed.filter((version): version is string => typeof version === 'string')

  if (typeof parsed === 'string')
    return [parsed]

  throw new Error(`Unexpected @capgo/cli versions response: ${JSON.stringify(parsed)}`)
}

export function resolvePublishedCliNpmInstallVersion(
  tag: string,
  runNpm: NpmRunner = defaultNpmRunner,
): string {
  const targetVersion = resolvePublishedCliNpmVersion(tag)
  const publishedVersions = normalizePublishedCliNpmVersions(JSON.parse(runNpm(['view', '@capgo/cli', 'versions', '--json'])))

  if (publishedVersions.includes(targetVersion))
    return targetVersion

  const installable = publishedVersions
    .filter(version => comparePublishedCliTags(`${PUBLISHED_CLI_TAG_PREFIX}${version}`, tag) <= 0)
    .sort((left, right) => comparePublishedCliTags(`${PUBLISHED_CLI_TAG_PREFIX}${left}`, `${PUBLISHED_CLI_TAG_PREFIX}${right}`))
    .at(-1)

  if (!installable) {
    throw new Error(
      `No published @capgo/cli npm version found for git tag ${tag}. `
      + `Latest npm release is ${publishedVersions.at(-1) ?? 'unknown'}.`,
    )
  }

  return installable
}

export function resolvePublishedCliRpcSourceTag(
  latestTag: string,
  npmInstallVersion: string,
  runGit: GitRunner = defaultGitRunner,
): string {
  const latestVersion = resolvePublishedCliNpmVersion(latestTag)
  if (latestVersion === npmInstallVersion)
    return latestTag

  const installTag = `${PUBLISHED_CLI_TAG_PREFIX}${npmInstallVersion}`
  try {
    runGit(['rev-parse', '-q', '--verify', `refs/tags/${installTag}`])
    return installTag
  }
  catch {
    throw new Error(
      `Published CLI npm version ${npmInstallVersion} has no matching git tag ${installTag}. `
      + `Cannot verify RPC contract without the CLI source for the package under test.`,
    )
  }
}

function findMatchingCloseParen(source: string, openParenIndex: number): number {
  let depth = 0
  let inString: '"' | '\'' | '`' | null = null
  let escaped = false

  for (let index = openParenIndex; index < source.length; index++) {
    const char = source[index]

    if (inString) {
      if (escaped) {
        escaped = false
        continue
      }
      if (char === '\\') {
        escaped = true
        continue
      }
      if (char === inString)
        inString = null
      continue
    }

    if (char === '"' || char === '\'' || char === '`') {
      inString = char
      continue
    }

    if (char === '(')
      depth++
    else if (char === ')') {
      depth--
      if (depth === 0)
        return index
    }
  }

  return -1
}

function extractKeysFromObjectLiteral(source: string, objectStart: number): string[] {
  let depth = 0
  let inString: '"' | '\'' | '`' | null = null
  let escaped = false

  for (let index = objectStart; index < source.length; index++) {
    const char = source[index]

    if (inString) {
      if (escaped) {
        escaped = false
        continue
      }
      if (char === '\\') {
        escaped = true
        continue
      }
      if (char === inString)
        inString = null
      continue
    }

    if (char === '"' || char === '\'' || char === '`') {
      inString = char
      continue
    }

    if (char === '{') {
      depth++
      continue
    }

    if (char === '}') {
      depth--
      if (depth === 0) {
        const argsSource = source.slice(objectStart + 1, index)
        const keys = new Set<string>()
        for (const segment of splitTopLevelArgSegments(argsSource)) {
          const trimmed = segment.trim()
          if (!trimmed)
            continue

          const explicit = trimmed.match(/^([a-zA-Z_][\w]*)\s*:/)
          if (explicit) {
            keys.add(explicit[1]!)
            continue
          }

          const shorthand = trimmed.match(/^([a-zA-Z_][\w]*)$/)
          if (shorthand)
            keys.add(shorthand[1]!)
        }

        return [...keys].sort()
      }
    }
  }

  return []
}

function extractArgKeysFromRpcArgExpression(source: string, argStart: number, rpcCloseParen: number): string[] {
  const keys = new Set<string>()
  let inString: '"' | '\'' | '`' | null = null
  let escaped = false
  let braceDepth = 0

  for (let index = argStart; index < rpcCloseParen; index++) {
    const char = source[index]

    if (inString) {
      if (escaped) {
        escaped = false
        continue
      }
      if (char === '\\') {
        escaped = true
        continue
      }
      if (char === inString)
        inString = null
      continue
    }

    if (char === '"' || char === '\'' || char === '`') {
      inString = char
      continue
    }

    if (char === '{') {
      if (braceDepth === 0) {
        for (const key of extractKeysFromObjectLiteral(source, index))
          keys.add(key)
      }
      braceDepth++
      continue
    }

    if (char === '}')
      braceDepth--
  }

  return [...keys].sort()
}

export function extractArgKeysFromRpcCall(source: string, afterRpcNameIndex: number): string[] | null {
  const rpcOpenParenIndex = source.lastIndexOf('(', afterRpcNameIndex)
  if (rpcOpenParenIndex === -1)
    return []

  const rpcCloseParen = findMatchingCloseParen(source, rpcOpenParenIndex)
  if (rpcCloseParen === -1)
    return []

  let cursor = afterRpcNameIndex
  const castMatch = source.slice(cursor).match(/^\s*as\s+any/)
  if (castMatch)
    cursor += castMatch[0].length

  const remainder = source.slice(cursor).trimStart()
  if (remainder.startsWith(')'))
    return []

  const commaMatch = source.slice(cursor).match(/^\s*,/)
  if (!commaMatch)
    return null

  cursor += commaMatch[0].length
  const afterComma = source.slice(cursor).trimStart()
  if (afterComma.startsWith('{')) {
    const objectStart = source.indexOf('{', cursor)
    if (objectStart === -1 || objectStart >= rpcCloseParen)
      return null

    return extractKeysFromObjectLiteral(source, objectStart)
  }

  const keys = extractArgKeysFromRpcArgExpression(source, cursor, rpcCloseParen)
  if (keys.length > 0)
    return keys

  const argExpression = source.slice(cursor, rpcCloseParen).trim()
  return argExpression ? null : []
}

function splitTopLevelArgSegments(source: string): string[] {
  const segments: string[] = []
  let start = 0
  let depth = 0
  let inString: '"' | '\'' | '`' | null = null
  let escaped = false

  for (let index = 0; index < source.length; index++) {
    const char = source[index]

    if (inString) {
      if (escaped) {
        escaped = false
        continue
      }
      if (char === '\\') {
        escaped = true
        continue
      }
      if (char === inString)
        inString = null
      continue
    }

    if (char === '"' || char === '\'' || char === '`') {
      inString = char
      continue
    }

    if (char === '{' || char === '[' || char === '(')
      depth++
    else if (char === '}' || char === ']' || char === ')')
      depth--
    else if (char === ',' && depth === 0) {
      segments.push(source.slice(start, index))
      start = index + 1
    }
  }

  if (start < source.length)
    segments.push(source.slice(start))

  return segments
}

function formatPublishedCliRpcCallKey(call: PublishedCliRpcCall): string {
  if (call.argKeys === null)
    return `${call.name}(?)`
  return `${call.name}(${call.argKeys.join(',')})`
}

function comparePublishedCliRpcCalls(left: PublishedCliRpcCall, right: PublishedCliRpcCall): number {
  const byName = left.name.localeCompare(right.name)
  if (byName !== 0)
    return byName
  return formatPublishedCliRpcCallKey(left).localeCompare(formatPublishedCliRpcCallKey(right))
}

function rpcNameEndIndex(source: string, match: RegExpMatchArray): number {
  return (match.index ?? 0) + match[0].length
}

export function extractPublishedCliRpcCallsFromSource(source: string): PublishedCliRpcCall[] {
  const calls = new Map<string, PublishedCliRpcCall>()

  for (const match of source.matchAll(PUBLISHED_CLI_RPC_PATTERN)) {
    const name = match[1]!
    const argKeys = extractArgKeysFromRpcCall(source, rpcNameEndIndex(source, match))
    const call = { name, argKeys }
    calls.set(formatPublishedCliRpcCallKey(call), call)
  }

  return [...calls.values()].sort(comparePublishedCliRpcCalls)
}

// git grep exits 1 when nothing matches: that is an empty result, not a failure.
function isGitGrepNoMatch(error: unknown) {
  return !!error && typeof error === 'object' && 'status' in error && error.status === 1
}

export function extractPublishedCliRpcCalls(tag: string, runGit: GitRunner = defaultGitRunner): PublishedCliRpcCall[] {
  let output: string
  try {
    output = runGit(['grep', '-l', '-E', String.raw`\.rpc\(["'\`][a-z][a-z0-9_]*["'\`]`, tag, '--', 'cli/src'])
  }
  catch (error) {
    if (isGitGrepNoMatch(error))
      return []
    throw error
  }
  const files = output
    .split('\n')
    .map(line => line.trim())
    .filter(Boolean)
    .map((line) => {
      const tagPrefix = `${tag}:`
      return line.startsWith(tagPrefix) ? line.slice(tagPrefix.length) : line
    })

  const calls = new Map<string, PublishedCliRpcCall>()
  for (const file of files) {
    const source = runGit(['show', `${tag}:${file}`])
    for (const call of extractPublishedCliRpcCallsFromSource(source)) {
      calls.set(formatPublishedCliRpcCallKey(call), call)
    }
  }

  return [...calls.values()].sort(comparePublishedCliRpcCalls)
}

// Newer CLIs stop calling RPCs directly, but customers still run older releases that do.
// Walk back from sourceTag to the newest published CLI that still calls RPCs so their grants stay protected.
export function resolvePublishedCliRpcContract(
  sourceTag: string,
  runGit: GitRunner = defaultGitRunner,
): { rpcSourceTag: string, rpcCalls: PublishedCliRpcCall[] } {
  const candidates = listPublishedCliTags(runGit)
    .filter(tag => comparePublishedCliTags(tag, sourceTag) <= 0)
    .reverse()
  for (const tag of candidates) {
    const rpcCalls = extractPublishedCliRpcCalls(tag, runGit)
    if (rpcCalls.length > 0)
      return { rpcSourceTag: tag, rpcCalls }
  }
  return { rpcSourceTag: sourceTag, rpcCalls: [] }
}

export function formatPublishedCliRpcCall(call: PublishedCliRpcCall): string {
  if (call.argKeys === null)
    return `${call.name}(<dynamic>)`
  if (call.argKeys.length === 0)
    return `${call.name}()`
  return `${call.name}({ ${call.argKeys.join(', ')} })`
}

export function rpcCallMatchesOverload(
  call: PublishedCliRpcCall,
  argNames: string[] | null,
  defaultCount: number,
  argCount: number,
): boolean {
  if (call.argKeys === null)
    return true

  const names = argNames ?? []

  if (call.argKeys.length === 0)
    return argCount === 0 || defaultCount === argCount

  const provided = new Set(call.argKeys)
  if (!call.argKeys.every(key => names.includes(key)))
    return false

  const requiredNames = names.slice(0, Math.max(argCount - defaultCount, 0))
  return requiredNames.every(name => provided.has(name))
}