Skip to content

Codegen: procedure-names.gen.ts packs a whole module onto one line, so any two branches touching that module conflict — and neither side is the right answer #466

Description

@MikeyZhang75

Summary

procedure-names.gen.ts emits one physical line per source module, holding every procedure in that module. It is committed, and the values it holds are absolute line numbers, so any edit that shifts a procedure's definition line rewrites the whole line.

Two consequences, and the second is the bad one:

  1. Any two branches that touch the same module conflict on that line — even when they add unrelated procedures at opposite ends of the file.
  2. Neither side of that conflict is the correct merge result. The correct line numbers account for both branches' insertions and therefore exist on neither. Because the file is generated and carries // Do not edit manually, the natural resolution is to take one side — which silently produces a lookup that misses.

In this repo, the hottest module is one 1730-character line holding 27 entries, and 17 of the 32 commits since the file landed (kitcn 0.25.5, 2026-08-21) rewrite it — so ~53% of PRs carry a merge-hostile change to it.

Repro

emitProcedureNameLookupLiteral joins a module's entries with ', ' onto a single line (packages/kitcn/src/cli/codegen.ts:291):

const items = locations
  .map((location) => `{ column: ${location.column}, line: ${location.line}, name: ${JSON.stringify(location.name)} }`)
  .join(', ');

return `  ${JSON.stringify(file)}: [${items}],`;

Given a base with two procedures in externalCodes.ts, branch A adding one above them (shifting both by 12) and branch B adding one between them (shifting the last by 20) — both branches ran codegen correctly:

$ git merge feat-a
CONFLICT (content): Merge conflict in gen/procedure-names.gen.ts
export const procedureNames = {
<<<<<<< HEAD
  "externalCodes.ts": [{ column: 2, line: 129, name: "externalCodes:create" }, { column: 2, line: 300, name: "externalCodes:export" }, { column: 2, line: 466, name: "externalCodes:count" }],
=======
  "externalCodes.ts": [{ column: 2, line: 40, name: "externalCodes:archive" }, { column: 2, line: 141, name: "externalCodes:create" }, { column: 2, line: 458, name: "externalCodes:count" }],
>>>>>>> feat-a
  "redeem.ts": [{ column: 2, line: 171, name: "redeem:runRedeemJob" }],
};

The merged module contains archive, create, export and count. Every line number on both sides of that conflict is wrong for it, and the file names only 3 of the 4 procedures either way. The only correct resolution is to discard both sides and re-run codegen — which nothing in the file or the conflict tells you.

Why a miss is silent

findBestEntry requires exact line equality (packages/kitcn/src/server/procedure-name.ts:173):

const sameLine = entries.filter((entry) => entry.line === location.line);
if (sameLine.length === 0) { return; }
return sameLine.reduce(/* nearest column */);

On a miss inferProcedureNameFromCallsite() returns undefined (:201), and resolveProcedureInfo's only other source is Symbol.for('functionName') on the registered function. Convex sets that symbol on function references (api.x.y, server/api.js:28), not on the object returned by customFunction(...) — registration_impl.js never touches it. So info.name is undefined in every middleware for that procedure. No throw, no warning, no codegen diff.

Worse, column cannot rescue it: it is only a tie-break within an already-matching line. If a stale entry's recorded line collides with a different procedure's actual callsite line — plausible after a whole-module shift across 27 entries — the middleware receives a confidently wrong name. I have not observed that in the wild; the mechanism permits it.

Suggested fix

1. One entry per line (cheap, self-contained). Change the join(', ') at codegen.ts:291 to a newline + indent. Entries are already emitted name-sorted, not line-sorted, so a line shift does not reorder the list — the diff (and the conflict) collapses from "the whole module" to "only the procedures whose line actually moved". Two branches adding procedures at opposite ends of a module would then merge cleanly. It does not make every clean merge correct — absolute positions in a shared file can't be — but it removes the systematic whole-module conflict and shrinks the wrong-resolution surface to the entries that genuinely overlap.

2. Make a miss loud rather than silent. findBestEntry returning undefined is currently indistinguishable from "this file has no procedures". A dev-mode warning when a module has recorded entries but none match the callsite line would turn a mis-merged file into a visible error instead of quietly unnamed procedures. This is what makes the whole class of bug survivable, whichever encoding stays.

3. Worth considering: drop positions entirely. kitcn already generates a per-module registry keyed by export name with no positions at all, one entry per line, and it merges fine:

// generated/retrieve.runtime.ts
const procedureRegistry = {
  "probeCard": ["action", typedProcedureResolver(...("retrieve:probeCard"), ...)],
  "runRetrieveJob": ["action", typedProcedureResolver(...("retrieve:runRetrieveJob"), ...)],
} as const;

codegen.ts:285 already builds exactly the string the positional lookup is trying to recover (${params.moduleName}:${procedure.exportName}). I realise the callsite lookup exists because the builder cannot know its own export binding at construction time, so this is not a drop-in — but it means the information is duplicated in two generated files, one of which encodes it in the only form that cannot survive a merge.

Environment

  • kitcn 0.32.1, convex 1.44.0
  • verified against origin/main at c12407fc
  • file landed here on kitcn 0.25.5; measurements above are 24 days of one repo's history

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions