Ctrl K
ComunidadGuías…

utils/changeImpact.ts

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

Archivo original ↗Preguntar a KiBot
import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import type { RestEndpointMethodTypes } from "@octokit/rest";
import type { ToolContext } from "../mcp/server.ts";
import { log } from "./cli.ts";
import { trustedGit } from "./gitAuth.ts";
import { resolveEnv } from "./secrets.ts";

type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];

type ChangeLocation = {
  path: string;
  line: number;
  kind: "added" | "removed";
};

type AtomChange = {
  atom: string;
  confidence: number;
  added: ChangeLocation[];
  removed: ChangeLocation[];
};

type Reference = {
  path: string;
  line: number;
  text: string;
  truncated: boolean;
};

type ReferenceBucket = {
  count: number;
  samples: Reference[];
};

type ChangeCollection = {
  changes: Map<string, AtomChange>;
  addedLines: Set<string>;
};

type CreateChangeImpactParams = {
  files: PullFile[];
  pullNumber: number;
  baseSha: string;
  headSha: string;
  signal: AbortSignal;
};

type LookupReferencesParams = {
  candidates: AtomChange[];
  addedLines: Set<string>;
  treeish: string;
  signal: AbortSignal;
};

type ImpactEntry = {
  change: AtomChange;
  bucket: ReferenceBucket;
};

type GrepRecordState = {
  phase: "path" | "line" | "text";
  path: string;
  line: string;
  sample: string;
  truncated: boolean;
  searchTail: string;
  searchStart: number;
  matches: Set<string>;
};

type ChangeImpactResult = {
  path: string;
  candidateCount: number;
  renderedAtomCount: number;
  referenceCount: number;
  bytes: number;
};

const MAX_CANDIDATES = 24;
const MAX_ATOM_LENGTH = 128;
const MAX_ATOMS = 12;
const MAX_REFERENCES = 6;
const MAX_EXCERPT = 160;
const MAX_BYTES = 16_000;
const LOOKUP_BUDGET_MS = 60_000;

function isSymbolShaped(atom: string): boolean {
  return atom.length >= 4 && atom.length <= MAX_ATOM_LENGTH && /[A-Z_]/.test(atom);
}

function getAtomConfidence(params: { text: string; atom: string; index: number }): number {
  const before = params.text.slice(Math.max(0, params.index - 64), params.index);
  const after = params.text.slice(
    params.index + params.atom.length,
    params.index + params.atom.length + 16
  );
  if (/\b(?:class|interface|type|enum|function|def|func|fn|struct|trait)\s+$/.test(before))
    return 4;
  if (/\b(?:const|let|var)\s+$/.test(before) || /^\s*\??\s*[:(]/.test(after)) return 3;
  const quoted = /['"`]$/.test(before) && /^['"`]/.test(after);
  if (quoted || before.endsWith(".") || after.startsWith(".") || params.atom.includes("_"))
    return 2;
  return 1;
}

function getChangedAtoms(text: string): Map<string, number> {
  const atoms = new Map<string, number>();
  for (const match of text.matchAll(/[A-Za-z_][A-Za-z0-9_]*/g)) {
    const atom = match[0];
    if (!isSymbolShaped(atom)) continue;
    const confidence = getAtomConfidence({ text, atom, index: match.index ?? 0 });
    atoms.set(atom, Math.max(atoms.get(atom) ?? 0, confidence));
  }
  return atoms;
}

function recordChange(params: {
  changes: Map<string, AtomChange>;
  location: ChangeLocation;
  text: string;
}) {
  for (const [atom, confidence] of getChangedAtoms(params.text)) {
    const change = params.changes.get(atom) ?? { atom, confidence, added: [], removed: [] };
    change.confidence = Math.max(change.confidence, confidence);
    change[params.location.kind].push(params.location);
    params.changes.set(atom, change);
  }
}

function locationKey(location: ChangeLocation | Reference): string {
  return `${location.path}\0${location.line}`;
}

function collectFileChanges(collection: ChangeCollection, file: PullFile): void {
  if (!file.patch) return;
  let oldLine = 0;
  let newLine = 0;

  for (const patchLine of file.patch.split("\n")) {
    const hunk = patchLine.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
    if (hunk) {
      oldLine = Number.parseInt(hunk[1], 10);
      newLine = Number.parseInt(hunk[2], 10);
    } else if (patchLine.startsWith("+")) {
      const location: ChangeLocation = { path: file.filename, line: newLine++, kind: "added" };
      collection.addedLines.add(locationKey(location));
      recordChange({ changes: collection.changes, location, text: patchLine.slice(1) });
    } else if (patchLine.startsWith("-")) {
      const oldPath = file.previous_filename ?? file.filename;
      recordChange({
        changes: collection.changes,
        location: { path: oldPath, line: oldLine++, kind: "removed" },
        text: patchLine.slice(1),
      });
    } else if (!patchLine.startsWith("\\")) {
      oldLine++;
      newLine++;
    }
  }
}

function compareChanges(a: AtomChange, b: AtomChange): number {
  if (a.confidence !== b.confidence) return b.confidence - a.confidence;
  const removedDelta = Number(b.removed.length > 0) - Number(a.removed.length > 0);
  if (removedDelta !== 0) return removedDelta;
  const aDelta = Math.abs(a.added.length - a.removed.length);
  const bDelta = Math.abs(b.added.length - b.removed.length);
  if (aDelta !== bDelta) return bDelta - aDelta;
  if (a.atom.length !== b.atom.length) return b.atom.length - a.atom.length;
  return a.atom < b.atom ? -1 : Number(a.atom > b.atom);
}

function getCandidates(files: PullFile[]) {
  const collection = {
    changes: new Map<string, AtomChange>(),
    addedLines: new Set<string>(),
  };
  for (const file of files) collectFileChanges(collection, file);
  const candidates = [...collection.changes.values()]
    .filter((change) => change.added.length !== change.removed.length)
    .sort(compareChanges);
  return {
    selected: candidates.slice(0, MAX_CANDIDATES),
    total: candidates.length,
    addedLines: collection.addedLines,
  };
}

function emptyBuckets(candidates: AtomChange[]): Map<string, ReferenceBucket> {
  return new Map(candidates.map((candidate) => [candidate.atom, { count: 0, samples: [] }]));
}

function createGrepRecordState(): GrepRecordState {
  return {
    phase: "path",
    path: "",
    line: "",
    sample: "",
    truncated: false,
    searchTail: "",
    searchStart: 0,
    matches: new Set<string>(),
  };
}

function resetGrepRecord(state: GrepRecordState): void {
  state.phase = "path";
  state.path = "";
  state.line = "";
  state.sample = "";
  state.truncated = false;
  state.searchTail = "";
  state.searchStart = 0;
  state.matches.clear();
}

function isWordCharacter(value: string | undefined): boolean {
  return value !== undefined && /[A-Za-z0-9_]/.test(value);
}

function recordCandidateMatches(params: {
  text: string;
  minStart: number;
  maxStart: number;
  candidates: AtomChange[];
  matches: Set<string>;
}) {
  for (const candidate of params.candidates) {
    if (params.matches.has(candidate.atom)) continue;
    let index = params.text.indexOf(candidate.atom, params.minStart);
    while (index >= 0 && index < params.maxStart) {
      const before = params.text[index - 1];
      const after = params.text[index + candidate.atom.length];
      if (!isWordCharacter(before) && !isWordCharacter(after)) {
        params.matches.add(candidate.atom);
        break;
      }
      index = params.text.indexOf(candidate.atom, index + 1);
    }
  }
}

function consumeReferenceText(params: {
  state: GrepRecordState;
  text: string;
  candidates: AtomChange[];
  maxAtomLength: number;
  final: boolean;
}) {
  const remaining = MAX_EXCERPT - params.state.sample.length;
  params.state.sample += params.text.slice(0, Math.max(remaining, 0));
  if (params.text.length > remaining) params.state.truncated = true;

  const searchable = params.state.searchTail + params.text;
  const consumed = params.final
    ? searchable.length
    : Math.max(0, searchable.length - params.maxAtomLength - 2);
  recordCandidateMatches({
    text: searchable,
    minStart: params.state.searchStart,
    maxStart: consumed,
    candidates: params.candidates,
    matches: params.state.matches,
  });
  if (params.final) {
    params.state.searchTail = "";
    params.state.searchStart = 0;
    return;
  }
  const tailStart = Math.max(0, consumed - 1);
  params.state.searchTail = searchable.slice(tailStart);
  params.state.searchStart = consumed > 0 ? 1 : params.state.searchStart;
}

function recordGrepReference(params: {
  state: GrepRecordState;
  buckets: Map<string, ReferenceBucket>;
  addedLines: Set<string>;
  treeish: string;
}) {
  const prefix = `${params.treeish}:`;
  const path = params.state.path.startsWith(prefix)
    ? params.state.path.slice(prefix.length)
    : params.state.path;
  const line = Number.parseInt(params.state.line, 10);
  if (!Number.isFinite(line)) return;
  const reference = {
    path,
    line,
    text: params.state.sample,
    truncated: params.state.truncated,
  };
  if (params.addedLines.has(locationKey(reference))) return;
  for (const atom of params.state.matches) {
    const bucket = params.buckets.get(atom);
    if (!bucket) continue;
    bucket.count++;
    if (bucket.samples.length < MAX_REFERENCES) bucket.samples.push(reference);
  }
}

function consumeGrepChunk(params: {
  chunk: string;
  state: GrepRecordState;
  candidates: AtomChange[];
  maxAtomLength: number;
  buckets: Map<string, ReferenceBucket>;
  addedLines: Set<string>;
  treeish: string;
}) {
  let offset = 0;
  while (offset < params.chunk.length) {
    if (params.state.phase !== "text") {
      const end = params.chunk.indexOf("\0", offset);
      const value = end < 0 ? params.chunk.slice(offset) : params.chunk.slice(offset, end);
      params.state[params.state.phase] += value;
      if (end < 0) return;
      params.state.phase = params.state.phase === "path" ? "line" : "text";
      offset = end + 1;
      continue;
    }

    const end = params.chunk.indexOf("\n", offset);
    const final = end >= 0;
    const text = final ? params.chunk.slice(offset, end) : params.chunk.slice(offset);
    consumeReferenceText({
      state: params.state,
      text,
      candidates: params.candidates,
      maxAtomLength: params.maxAtomLength,
      final,
    });
    if (!final) return;
    recordGrepReference(params);
    resetGrepRecord(params.state);
    offset = end + 1;
  }
}

async function lookupReferences(params: LookupReferencesParams) {
  const buckets = emptyBuckets(params.candidates);
  if (params.candidates.length === 0) return { buckets, error: undefined };

  // ONE alternation, not an `-e` per atom: after every matching line git's look-ahead rescans each
  // pattern to EOF, so one absent atom made the grep quadratic (20+ min on a 644 MB tree, #1468).
  // atoms are word characters only, so they need no escaping.
  const pattern = params.candidates.map((candidate) => candidate.atom).join("|");
  // outside the sandbox because the lookup budget has to be able to kill it, and a signal cannot
  // reach through sudo and `unshare --fork`. `git grep` on a tree runs no hook, filter or fsmonitor.
  const child = spawn(
    trustedGit(),
    [
      "grep",
      "--no-color",
      "--full-name",
      "-n",
      "-z",
      "-I",
      "-w",
      "-E",
      "-e",
      pattern,
      params.treeish,
      "--",
      ":(top)",
    ],
    { env: resolveEnv(undefined), stdio: ["ignore", "pipe", "pipe"], signal: params.signal }
  );
  const state = createGrepRecordState();
  const maxAtomLength = Math.max(...params.candidates.map((candidate) => candidate.atom.length));
  let spawnError: string | undefined;

  child.stdout.setEncoding("utf8");
  child.stdout.on("data", (chunk) => {
    consumeGrepChunk({
      chunk,
      state,
      candidates: params.candidates,
      maxAtomLength,
      buckets,
      addedLines: params.addedLines,
      treeish: params.treeish,
    });
  });
  child.stderr.resume();
  const status = await new Promise<number | null>((resolve) => {
    child.once("error", (error) => {
      // an abort's `cause` is the signal's reason: the budget's TimeoutError or the checkout deadline
      spawnError = error.cause instanceof Error ? error.cause.message : error.message;
      resolve(null);
    });
    child.once("close", resolve);
  });

  if (spawnError || (status !== 0 && status !== 1)) {
    const error = spawnError ?? `git grep exit ${status ?? "unknown"}`;
    log.warning(`» change impact lookup failed: ${error}`);
    return { buckets: emptyBuckets(params.candidates), error };
  }
  if (state.phase === "text") {
    consumeReferenceText({
      state,
      text: "",
      candidates: params.candidates,
      maxAtomLength,
      final: true,
    });
    recordGrepReference({ state, buckets, addedLines: params.addedLines, treeish: params.treeish });
  } else if (state.phase !== "path" || state.path) {
    const error = "malformed git grep output";
    log.warning(`» change impact lookup failed: ${error}`);
    return { buckets: emptyBuckets(params.candidates), error };
  }
  return { buckets, error: undefined };
}

function formatReference(reference: Reference): string {
  const text = reference.text.trim();
  const excerpt = reference.truncated ? `${text.slice(0, MAX_EXCERPT - 1)}…` : text;
  return `- ${JSON.stringify(reference.path)}:${reference.line} ${JSON.stringify(excerpt)}`;
}

function formatChangedLocations(change: AtomChange): string {
  const selected = [change.removed[0], change.added[0]].filter(
    (location) => location !== undefined
  );
  const rendered = selected
    .map((location) => `${location.kind} ${JSON.stringify(location.path)}:${location.line}`)
    .join(", ");
  const omitted = change.removed.length + change.added.length - selected.length;
  return omitted > 0 ? `${rendered}; ${omitted} more changed location(s) omitted` : rendered;
}

function formatEntry(entry: ImpactEntry): string {
  const count = entry.bucket.count;
  const sampleNote =
    count > entry.bucket.samples.length
      ? `${count} matched line(s); first ${entry.bucket.samples.length} shown.`
      : `${count} matched line(s).`;
  return [
    `## \`${entry.change.atom}\``,
    "",
    `Changed at: ${formatChangedLocations(entry.change)}`,
    `Tracked references: ${sampleNote}`,
    "",
    ...entry.bucket.samples.map(formatReference),
  ].join("\n");
}

function formatArtifact(params: {
  entries: ImpactEntry[];
  candidateCount: number;
  totalCandidateCount: number;
  fileCount: number;
  baseSha: string;
  headSha: string;
  lookupError: string | undefined;
}) {
  const lines = [
    "# Change impact leads",
    "",
    "> Supplemental and explicitly incomplete. Read the authoritative `diffPath` TOC and raw lines first. Absence here is not evidence of no impact.",
    "",
    `Scope: base ${params.baseSha}, head ${params.headSha}; ${params.fileCount} changed file(s).`,
    "Generated from changed symbol-shaped identifiers, then one exact-word lookup pinned to the checked-out head across Git-tracked text files. Changed added lines are excluded; snippets are capped at 160 characters.",
    "",
    `Candidate atoms searched: ${params.candidateCount}/${params.totalCandidateCount}.`,
  ];
  if (params.lookupError) lines.push(`Tracked-file lookup unavailable: ${params.lookupError}.`);
  let content = `${lines.join("\n")}\n`;
  let renderedAtomCount = 0;
  let referenceCount = 0;

  for (const entry of params.entries.filter((candidate) => candidate.bucket.count > 0)) {
    if (renderedAtomCount >= MAX_ATOMS) break;
    const addition = `\n${formatEntry(entry)}\n`;
    if (Buffer.byteLength(content + addition) > MAX_BYTES) break;
    content += addition;
    renderedAtomCount++;
    referenceCount += entry.bucket.count;
  }
  if (renderedAtomCount === 0) {
    const empty =
      "\nNo bounded tracked references found. This is not evidence of no wider impact.\n";
    if (Buffer.byteLength(content + empty) <= MAX_BYTES) content += empty;
  }
  return { content, renderedAtomCount, referenceCount };
}

export async function createChangeImpactArtifact(
  ctx: ToolContext,
  params: CreateChangeImpactParams
): Promise<ChangeImpactResult> {
  const candidates = getCandidates(params.files);
  const lookup = await lookupReferences({
    candidates: candidates.selected,
    addedLines: candidates.addedLines,
    treeish: params.headSha,
    signal: AbortSignal.any([params.signal, AbortSignal.timeout(LOOKUP_BUDGET_MS)]),
  });
  // the checkout deadline drops the artifact; the lookup budget alone renders as "unavailable"
  params.signal.throwIfAborted();
  const artifact = formatArtifact({
    entries: candidates.selected.map((change) => {
      return { change, bucket: lookup.buckets.get(change.atom) ?? { count: 0, samples: [] } };
    }),
    candidateCount: candidates.selected.length,
    totalCandidateCount: candidates.total,
    fileCount: params.files.length,
    baseSha: params.baseSha,
    headSha: params.headSha,
    lookupError: lookup.error,
  });
  const path = join(ctx.tmpdir, `pr-${params.pullNumber}-${params.headSha.slice(0, 7)}-impact.md`);
  writeFileSync(path, artifact.content);
  return {
    path,
    candidateCount: candidates.selected.length,
    renderedAtomCount: artifact.renderedAtomCount,
    referenceCount: artifact.referenceCount,
    bytes: Buffer.byteLength(artifact.content),
  };
}