-
Notifications
You must be signed in to change notification settings - Fork 2.2k
fix(codemod): keep a file's leading comment block above rewritten imports #2582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@modelcontextprotocol/codemod': patch | ||
| --- | ||
|
|
||
| The `v1-to-v2` codemod now writes rewritten imports where the first v1 import stood, not at the top of the file, so a license header, `// @ts-nocheck`, `/// <reference>` or a `'use client'` / `'use server'` / `'use strict'` directive above it stays in place. Known gap: when a later step of the codemod replaces or removes the import (for example a file whose only SDK import is `ErrorCode` or `StreamableHTTPError`), the new import can still land above or inside the header, and a `/** */` header can be removed. Files already migrated with codemod 2.1.0 or earlier are not repaired; check the top of those files. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,7 +52,11 @@ export const importPathsTransform: Transform = { | |
| return spec.includes('/server/'); | ||
| }); | ||
|
|
||
| const insertIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!); | ||
| const importIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!); | ||
| // ts-morph inserts by position among all top-level children (own-line comments and statements count), not among imports. | ||
| const childIndex = sdkImports[0]!.getChildIndex(); | ||
| const previousEnd = childIndex > 0 ? sourceFile.getStatementsWithComments()[childIndex - 1]!.getEnd() : 0; | ||
| const blankLineAbove = childIndex > 0 && /^[ \t]*\r?\n[ \t]*\r?\n/.test(sourceFile.getFullText().slice(previousEnd)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): a blank line between a preceding import that ends in a trailing comment and the re-emitted SDK import is dropped after migration. blankLineAbove at importPaths.ts:59 tests the text from getEnd(), which for Why this was flaggedInput: Verification: nit — triggered when the statement directly above the first SDK import carries a same-line trailing comment (e.g. |
||
|
|
||
| // A leading file-header / JSDoc comment attaches to the first SDK import as leading trivia. When | ||
| // that import is removed and re-emitted (the per-symbol split/merge path calls imp.remove()), | ||
|
|
@@ -62,9 +66,12 @@ export const importPathsTransform: Transform = { | |
| // (a blank line, or CRLF in CRLF files), so the later survival check would never match a header | ||
| // that actually survived (in-place setModuleSpecifier rewrite) and would re-insert it, duplicating | ||
| // it. The slice reproduces the block verbatim, so the includes() guard below is byte-exact. | ||
| const leadingRanges = sdkImports[0]!.getLeadingCommentRanges(); | ||
| // Own-line comments above the import are siblings and survive its removal; only comments attached to it can be dropped. | ||
| const leadingRanges = sdkImports[0]!.getLeadingCommentRanges().filter(range => range.getPos() >= previousEnd); | ||
| const leadingCommentText = | ||
| leadingRanges.length > 0 ? sourceFile.getFullText().slice(leadingRanges[0]!.getPos(), leadingRanges.at(-1)!.getEnd()) : ''; | ||
| const leadingCommentGap = | ||
| leadingRanges.length > 0 ? sourceFile.getFullText().slice(leadingRanges.at(-1)!.getEnd(), sdkImports[0]!.getStart()) : ''; | ||
|
|
||
| interface PendingImport { | ||
| specs: NamedImportSpec[]; | ||
|
|
@@ -334,6 +341,10 @@ export const importPathsTransform: Transform = { | |
| } | ||
| } | ||
|
|
||
| // New imports go where the first SDK import stood, or right below it when it was rewritten in place and still stands there. | ||
| const firstRemoved = sdkImports[0]!.wasForgotten(); | ||
| const insertIndex = firstRemoved ? childIndex : childIndex + 1; | ||
|
|
||
| const specLocal = (spec: NamedImportSpec): string => (typeof spec === 'string' ? spec : (spec.alias ?? spec.name)); | ||
| for (const [target, groups] of pendingImports) { | ||
| // Dedupe by local binding name (alias when present), keeping the spec so aliases survive. | ||
|
|
@@ -345,21 +356,31 @@ export const importPathsTransform: Transform = { | |
| } | ||
| } | ||
|
|
||
| let valueInserted = false; | ||
| if (valueSpecs.size > 0) { | ||
| addOrMergeImport(sourceFile, target, [...valueSpecs.values()], false, insertIndex); | ||
| valueInserted = addOrMergeImport( | ||
| sourceFile, | ||
| target, | ||
| [...valueSpecs.values()], | ||
| false, | ||
| insertIndex, | ||
| firstRemoved && blankLineAbove | ||
| ); | ||
| } | ||
| if (typeOnlySpecs.size > 0) { | ||
| const typeInsertIndex = valueSpecs.size > 0 ? insertIndex + 1 : insertIndex; | ||
| addOrMergeImport(sourceFile, target, [...typeOnlySpecs.values()], true, typeInsertIndex); | ||
| // The type import goes one lower only when the value import was inserted, not when it merged into an existing import. | ||
| const typeInsertIndex = valueInserted ? insertIndex + 1 : insertIndex; | ||
| const blankLine = firstRemoved && blankLineAbove && !valueInserted; | ||
| addOrMergeImport(sourceFile, target, [...typeOnlySpecs.values()], true, typeInsertIndex, blankLine); | ||
| } | ||
|
Comment on lines
+359
to
375
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: Pre-existing gap the fix leaves open: a header's blank line is still consumed when the removed first SDK import produces no new declaration. The blank line is only re-emitted through the leadingTrivia of an inserted import (importPaths.ts:367 and :373), so when every pending value import merges into an existing v2 import and there are no type-only specs, or when the removed import yields no pending imports at all, nothing carries blankLineAbove and the header ends up touching the next statement. Fix: after the pending loop, when firstRemoved && blankLineAbove and no declaration was inserted at childIndex, re-insert the blank line above the statement now at childIndex (e.g. insertText of '\n' at its start), so the header keeps its separation on every path, not only the insert path. Why this was flaggedInput: '// Copyright (c) 2026 Example Corp.', a blank line, 'import { StreamableHTTPClientTransport } from '@ modelcontextprotocol/sdk/client/streamableHttp.js';', then 'import { Client } from '@ modelcontextprotocol/client';' (a partially migrated file, the same mixed state the PR's own test at importPaths.test.ts:905 models). importPaths.ts:57-59 computes childIndex=1 and blankLineAbove=true. importPaths.ts:333 calls imp.remove(); ts-morph joins the comment node and the Client import with a single newline, consuming the blank line. importPaths.ts:361 then calls addOrMergeImport, which finds the existing '@ modelcontextprotocol/client' import (importUtils.ts:80-91) and merges, returning false and never emitting the blankLineIfLastNot trivia; typeOnlySpecs is empty so importPaths.ts:374 never runs. Output is '// Copyright (c) 2026 Example Corp.' directly followed by 'import { Client, StreamableHTTPClientTransport } ...', the header now reads as a doc comment on the import. The same happens when the removed import's only specifiers are removedSymbols (no addPending) or the module… Verification: pre-existing — the base already drops the blank line on this path by the same route, and the PR's fix does not reach it. Trigger: the first SDK import sits under a header + blank line and is removed (line 333 |
||
| } | ||
|
|
||
| // Restore the captured leading comment if the rewrite dropped it (guard against duplication when | ||
| // the first import was rewritten in place and kept its comment). | ||
| if (leadingCommentText && !sourceFile.getFullText().includes(leadingCommentText)) { | ||
| const imports = sourceFile.getImportDeclarations(); | ||
| const anchor = imports[Math.min(insertIndex, imports.length - 1)]; | ||
| sourceFile.insertText(anchor ? anchor.getStart() : 0, `${leadingCommentText}\n`); | ||
| const anchor = imports[Math.min(importIndex, imports.length - 1)]; | ||
| sourceFile.insertText(anchor ? anchor.getStart() : 0, `${leadingCommentText}${leadingCommentGap}`); | ||
| } | ||
|
|
||
| return { changesCount, diagnostics, usedPackages }; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -64,14 +64,16 @@ function specLocalName(s: { name: string; alias?: string }): string { | |
| return s.alias ?? s.name; | ||
| } | ||
|
|
||
| /** Adds the names to an existing import of the module, or inserts a new import; returns true when it inserted one. */ | ||
| export function addOrMergeImport( | ||
| sourceFile: SourceFile, | ||
| moduleSpecifier: string, | ||
| namedImports: NamedImportSpec[], | ||
| isTypeOnly: boolean, | ||
| insertIndex: number | ||
| ): void { | ||
| if (namedImports.length === 0) return; | ||
| insertIndex: number, | ||
| blankLineAbove = false | ||
| ): boolean { | ||
| if (namedImports.length === 0) return false; | ||
|
|
||
| const specs = namedImports.map(n => toSpec(n)); | ||
|
|
||
|
|
@@ -86,6 +88,7 @@ export function addOrMergeImport( | |
| if (newSpecs.length > 0) { | ||
| existing.addNamedImports(newSpecs.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name }))); | ||
| } | ||
| return false; | ||
| } else { | ||
| const seen = new Set<string>(); | ||
| const deduped = specs.filter(s => { | ||
|
|
@@ -94,12 +97,14 @@ export function addOrMergeImport( | |
| seen.add(local); | ||
| return true; | ||
| }); | ||
| const clampedIndex = Math.min(insertIndex, sourceFile.getImportDeclarations().length); | ||
| const clampedIndex = Math.min(insertIndex, sourceFile.getStatementsWithComments().length); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 pre-existing, not blocking: pre-existing: a user whose only SDK import is StreamableHTTPError (or a schema-input import) still gets the replacement import placed above a license header or 'use client' directive, the #2575 shape this PR fixes. importUtils.ts:100 now clamps and inserts by statement index, but removedApis.ts:201 and symbolRenames.ts:737 still pass getImportDeclarations().length, an import count. With a comment node or directive at index 0 that lands before the last import, or at index 0 above the header when no import remains. Fix: pass every caller a statement-based position (child index after the last import via getStatementsWithComments, or let addOrMergeImport translate an import-relative index), which covers the 2 sites listed. Same pattern at 2 sites (removedApis.ts:201, symbolRenames.ts:737). [also at: packages/codemod/src/utils/importUtils.ts:100 - nit: REVIEW.md Completeness asks that a replaced pattern be swept from the package and every leftover site flagged: this PR changes Why this was flaggedInput: a file starting with Verification: pre-existing; acknowledged in diff: .changeset/codemod-preserve-file-header.md explicitly says "Known gap: when a later step of the codemod replaces or removes the import (for example a file whose only SDK import is |
||
| sourceFile.insertImportDeclaration(clampedIndex, { | ||
| moduleSpecifier, | ||
| namedImports: deduped.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name })), | ||
| isTypeOnly | ||
| isTypeOnly, | ||
| leadingTrivia: blankLineAbove ? writer => writer.blankLineIfLastNot() : undefined | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 (optional) Users with a directive directly above their SDK import, with no blank line, get an extra blank line inserted between the directive and the rewritten import, a diff the base branch never produced in that spot. importUtils.ts:100-106 inserts at a child index whose previous sibling is an ExpressionStatement, and ts-morph's _standardWrite emits blankLineIfLastNot for any previous member that is not an import or comment. Fix: only accept ts-morph's separator when blankLineAbove is true; otherwise re-emit the original gap (as leadingCommentGap does) so Why this was flaggedTrigger: a file beginning Verification: nit — triggered when a file's first SDK import sits directly under a directive prologue ( |
||
| }); | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 nit (optional): CLAUDE.md formatting asks for 2-space indentation: every added line in importPaths.ts, importUtils.ts, commentInsertion.test.ts and importPaths.test.ts is indented in 4-space steps (e.g. 8 spaces before
const importIndex). Semicolons and single quotes are fine. Fix: the repo's committed .prettierrc setstabWidth: 4andpnpm lint:allenforces it, so re-indenting to 2 spaces would fail lint; the compliant resolution is to bring the CLAUDE.md line in line with the Prettier config (or vice versa) rather than reformat this diff. Same instruction across all 4 changed code files.Why this was flagged
Nothing fails at runtime. The written rule (2-space) and the enforced tool config (.prettierrc tabWidth 4, checked by
pnpm lint:all) contradict each other, and this diff follows the tool rather than the text, as does all existing code in packages/codemod. The only cost is that contributors reading CLAUDE.md are told a convention the linter rejects; a one-line edit to CLAUDE.md removes the contradiction. Filed only because the instruction as written is broken by the added lines; this is a wording/layout nit for the maintainer to weigh.Verification: Base CLAUDE.md § Code Style Guidelines reads verbatim "- Formatting: 2-space indentation, semicolons required, single quotes preferred", and it binds the whole repo (no nested CLAUDE.md under packages/codemod). Added line packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts:55
const importIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!);is…