Skip to content

vp migrate keeps tsx because of a ` tsx ```` code fence in a comment #3297

Closed

Description

@ReemX

Describe the bug

vp migrate (1.4.0) migrates the tsx scripts to vpx but then reports that a plain .ts file "still uses tsx" and keeps the tsx devDependency. The file doesn't run or import tsx. It has a JSDoc example fenced as ```tsx:

/**
 * @example
 * ```tsx
 * const isEditing = useEditingStore(selectIsEditing);
 * ```
 */
export function useEditingStore() {}

Migration output:

• tsx scripts migrated to `vpx`
  - Kept the tsx devDependency in package.json: use-editing.ts still uses tsx.

Change the fence to ```ts and tsx is removed as expected.

I think it's the second alternative of TSX_CODE_REFERENCE in packages/cli/src/migration/migrator/tsx.ts, /['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s/, which reads the third backtick of the fence as the start of a template literal followed by tsx and a newline. Requiring that the backtick isn't preceded by another backtick, or skipping comments, would avoid it. I'm happy to open a PR with a test if that sounds right.

Reproduction

https://gist.github.com/ReemX/a9735dbc0463e097e121ae92c8398b07

Steps to reproduce

  1. Download the gist: a package with "start": "tsx index.ts", tsx in devDependencies, and the file above.
  2. pnpm install
  3. pnpm dlx --package=vite-plus@1.4.0 vp migrate --no-interactive

Expected: start becomes vpx index.ts and tsx is removed. Actual: the script is migrated but tsx is kept, with the warning above.

System Info

vp v1.4.0

Local vite-plus:
  vite-plus  v1.4.0

Tools:
  vite             v8.3.5
  rolldown         v1.2.15
  vitest           v5.0.5
  oxfmt            v0.74.0
  oxlint           v1.89.0
  oxlint-tsgolint  v7.0.2004
  tsdown           v0.23.1

vp env current isn't available; we don't use the global CLI. Windows 11, Node 24.22.1, pnpm 12.16.1.

Used Package Manager

pnpm

Validations

  • Read the Contributing Guidelines.
  • Check that there isn't already an issue for the same bug.
  • Confirm this is a Vite+ issue and not an upstream issue (Vite, Vitest, tsdown, Rolldown, or Oxc).
  • The provided reproduction is a minimal reproducible example.

Activity

  1. voidzero-bot commented on Oct 31, 2026

    @voidzero-bot
    Contributor
    • Reproduced: Yes
    • Exploration: Yes — View branch
    • Unit Test: Yes — packages/cli/src/migration/__tests__/tsx.spec.ts
    • Priority: P2: Has Workaround. This only happens when a .ts file contains a JSDoc/Markdown code fence labeled tsx, and the workaround is to change the fence label or remove tsx manually after migration.

    The TSX_CODE_REFERENCE regex in fileUsesTsx (packages/cli/src/migration/migrator/tsx.ts) matches the third backtick of a ```tsx fence as a backtick-quoted string immediately followed by tsx , so the post-rewrite dependency scan thinks the file runs tsx even though the reference is only inside a comment.

    I found a potential fix for this issue. The branch strips // and /* ... */ comments before running TSX_CODE_REFERENCE on code files, using the existing stripComments helper that preserves real string/template literals. This removes the false positive for JSDoc/Markdown fences while still catching actual imports, spawn calls, and loader flags.

    Full Triage Report

    Triage Report: issue #3297

    Summary

    vp migrate reports that a plain .ts file "still uses tsx" and keeps the tsx devDependency when the file only contains a JSDoc @example fenced as ```tsx. The reporter identifies the second alternative of TSX_CODE_REFERENCE (packages/cli/src/migration/migrator/tsx.ts) as the likely cause: the ` matching the third backtick of the code fence is then followed by tsx and a newline, which the regex reads as a string starting a tsx command.

    Expected vs. Actual

    • Expected: "start": "tsx index.ts" is migrated to "start": "vpx index.ts" and the tsx devDependency is removed, because the project does not actually run or import tsx.

    • Actual: The script is rewritten to vpx index.ts, but migration prints:

      - Kept the tsx devDependency in package.json: use-editing.ts still uses tsx.
      

      and keeps tsx in devDependencies.

    Environment

    • Vite+ / vp: v1.4.0 (global vp on PATH, plus the local checkout's built CLI packages/cli/dist/bin.js)
    • Node.js: v24.22.1
    • pnpm: v12.11.2 / v12.16.1 (migration output reported pnpm 12.16.1)
    • OS: Ubuntu (runner)

    Reproduction Path

    Created two fresh, dependency-pinned projects under /tmp/vite-plus-triage/gh-3297/repro and /tmp/vite-plus-triage/gh-3297/control.

    Each has:

    {
      "name": "repro-3297",
      "version": "1.0.0",
      "private": true,
      "type": "module",
      "scripts": {
        "start": "tsx index.ts"
      },
      "devDependencies": {
        "tsx": "^4.0.0"
      }
    }

    with index.ts:

    import { useEditingStore } from './use-editing.js';
    
    console.log(useEditingStore);

    The only difference is the comment fence in use-editing.ts:

    • Repro: ```tsx
    • Control: ```ts

    Commands run:

    cd /tmp/vite-plus-triage/gh-3297/repro
    node /home/runner/work/vite-plus/vite-plus/packages/cli/dist/bin.js migrate --no-interactive
    
    cd /tmp/vite-plus-triage/gh-3297/control
    node /home/runner/work/vite-plus/vite-plus/packages/cli/dist/bin.js migrate --no-interactive

    Results

    Repro (```tsx fence)

    ● tsx scripts detected. Auto-migrating to vpx...
    ● Formatting code...
    ● Code formatted
    ◇ Migrated . to Vite+ 1.4.0
    • Node 24.22.1  pnpm 12.16.1
    ✓ Dependencies installed in 1.2s
    • 2 config updates applied
    • tsx scripts migrated to `vpx`
    ! Warnings:
      - Kept the tsx devDependency in package.json: use-editing.ts still uses tsx.
    

    Final package.json still contains tsx:

    {
      "scripts": {
        "start": "vpx index.ts",
        "prepare": "vp config"
      },
      "devDependencies": {
        "tsx": "^4.0.0",
        "vite-plus": "catalog:"
      }
    }

    Control (```ts fence)

    ● tsx scripts detected. Auto-migrating to vpx...
    ● Formatting code...
    ● Code formatted
    ◇ Migrated . to Vite+ 1.4.0
    • Node 24.22.1  pnpm 12.16.1
    ✓ Dependencies installed in 316ms
    • 2 config updates applied
    • tsx scripts migrated to `vpx`
    

    Final package.json has no tsx:

    {
      "scripts": {
        "start": "vpx index.ts",
        "prepare": "vp config"
      },
      "devDependencies": {
        "vite-plus": "catalog:"
      }
    }

    Conclusion

    The reported behavior reproduces on the current checkout. A ```tsx code fence inside a JSDoc comment in a .ts file is enough to make findTsxUse / fileUsesTsx treat the file as referencing tsx, while changing the fence to ```ts lets vp migrate remove the dependency as expected.

    Observations

    The relevant regex appears to be:

    /['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s/

    as part of TSX_CODE_REFERENCE. In ```tsx, the ` matching the third backtick of the fence is immediately followed by tsx and a newline, so the alternative matches. The call path is migrateTsxToVpx -> findTsxUse -> sourceTreeMatches -> fileUsesTsx, which applies TSX_CODE_REFERENCE to every .ts source file.

    Diagnosis

    Affected code

    • packages/cli/src/migration/migrator/tsx.ts

    • Regex constant TSX_CODE_REFERENCE, specifically its second alternative at composite-line ~94:

      /['"`](?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s/
    • Consumer: fileUsesTsx(content: string, file: string) at ~line 626:

      if (CODE_EXTENSIONS.has(extension)) {
        return TSX_CODE_REFERENCE.test(content);
      }
    • Call path: migrateTsxToVpx → findTsxUse → sourceTreeMatches callback → fileUsesTsx.

    Why it fails

    TSX_CODE_REFERENCE is matched against the raw text of every .ts/.tsx/etc. file in the post-rewrite phase that decides whether tsx can safely be removed from devDependencies. The second alternative is meant to catch source strings that start a tsx command, e.g.:

    execSync(`tsx ./job.ts`);

    It does not try to avoid code fences in Markdown/JSDoc. In the repro file, the line:

     * ```tsx

    contains three consecutive backticks immediately followed by tsx and a newline. The regex engine is free to start the match at the third backtick; that backtick satisfies ['"], and tsx\smatchestsx` + the newline. Therefore the whole file is judged to "still use tsx" and the dependency is kept, even though the only occurrence is inside a documentation comment.

    Changing the fence to ```ts removes the false match, confirming the regex is the cause.

    Suggested fix approach

    Make the scan comment-aware for source-code extensions. The tsx.ts module already contains stripComments(source) for the per-script CommonJS/ESM checks; applying the same (or an equivalent) comment strip before testing TSX_CODE_REFERENCE would keep real strings and imports while removing JSDoc/Markdown content. Alternatively, the second alternative could be tightened with a negative lookbehind so the opening backtick cannot be preceded by another backtick (which is specifically what makes a code fence), e.g.:

    /(?<!`)(?:`)(?:npx |pnpm (?:exec )?|yarn |bunx )?tsx\s/

    as the backtick variant of the string-detection alternative. Either approach would fix the reported case; comment stripping is more defensive against other comment-borne false positives (for example, an // import 'tsx' or // node --import tsx note).

    Edge cases

    • A .ts file that actually contains execSync(tsx ./a.ts) should continue to keep tsx; stripComments preserves string literals, so this detection remains intact.
    • Block and line comments that mention tsx commands should no longer block dependency removal; that matches the intent of findTsxUse (detecting real runtime use).
    • Code fences outside comments (e.g. in Markdown files) are not in REFERENCE_EXTENSIONS and are not scanned by fileUsesTsx, so they are not affected.
    • The regex fix must not interfere with the other TSX_CODE_REFERENCE alternatives for imports (from 'tsx/esm/api'), loader flags, or process-spawn arrays.

    Baseline tests

    The migration's dedicated tsx unit suite passes on current main:

    pnpm -F vite-plus exec vitest run src/migration/__tests__/tsx.spec.ts
    # 150 passed
    

    A regression test for this issue should be added to migrateTsxToVpx cases (or exported unit-test surface) asserting that a .ts file with only a ```tsx JSDoc fence does not keep tsx.

    Confidence

    High.

    Verification

    Claim

    The reporter claims that vp migrate should remove tsx from devDependencies when the only remaining tsx reference is a ```tsx code fence inside a JSDoc comment in a .ts file. Instead, migration keeps the dependency and reports use-editing.ts still uses tsx.

    Research

    • Code intent: The TSX_CODE_REFERENCE constant is introduced in commit c891e5dd (feat(migrate): migrate tsx scripts to vpx #3179, "migrate tsx scripts to vpx"). Its own comment describes it as detecting uses "in code": imports, strings that start a tsx command, process calls, and loader flags. There is no mention of comments or documentation fences.

    • Feature documentation: The commit-level "What stays on tsx" section enumerates real runtime or configuration uses that keep the dependency (scripts, imports, shebangs, tool configs, CI workflows, shell scripts). Markdown/JSDoc fences are not listed as a retention reason; they do not execute tsx.

    • Surrounding tests: packages/cli/src/migration/__tests__/tsx.spec.ts explicitly asserts that non-code contexts (e.g. moduleFileExtensions: ['tsx']) should not keep tsx, showing the migration is designed to avoid false positives. Existing tests do not cover JSDoc code fences.

    • GitHub search: gh search issues --repo voidzero-dev/vite-plus "tsx code fence" returns only this issue; no open issue or PR states that documentation fences should keep tsx.

    Verdict

    Bug. The regex over-matches a documentation pattern that the migration explicitly does not consider a use of tsx, contradicting the documented intent in the feature commit and the existing false-positive avoidance in the test suite.

    Confidence

    High.

    Fix

    Changed files

    • packages/cli/src/migration/migrator/tsx.ts
    • packages/cli/src/migration/__tests__/tsx.spec.ts

    Rationale

    fileUsesTsx was testing the raw source text of code files with TSX_CODE_REFERENCE. Because the regex's second alternative, ['"](?:npx |pnpm ...)?tsx\s`, can start at the third backtick of a ```tsx JSDoc/Markdown fence, a plain documentation comment was misclassified as a runtime reference to tsx.

    The tsx.ts module already has a stripComments helper that removes // and /* ... */ comments while preserving string and template-literal contents (so real commands such as execSync('tsx ./a.ts') are still detected). Applying stripComments before TSX_CODE_REFERENCE removes the false positive for comment-borne fences while keeping all intentional code uses. The same treatment is applied to extensionless executable script files, which may be JavaScript with comments.

    Full diff

    diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts
    index e66c8f88..e9121ef6 100644
    --- a/packages/cli/src/migration/__tests__/tsx.spec.ts
    +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts
    @@ -742,4 +742,40 @@ describe('migrateTsxToVpx', () => {
           'Kept the tsx devDependency in package.json: `scripts` in packages/b/package.json still uses tsx.',
         ]);
       });
    +
    +  it('removes tsx when the only reference is a code fence inside a JSDoc comment', () => {
    +    write('package.json', {
    +      type: 'module',
    +      scripts: { start: 'tsx index.ts' },
    +      devDependencies: { tsx: '^4.20.0' },
    +    });
    +    write(
    +      'index.ts',
    +      [
    +        '/**',
    +        ' * @example',
    +        ' * ```tsx',
    +        ' * const isEditing = useEditingStore(selectIsEditing);',
    +        ' * ```',
    +        ' */',
    +        'export function useEditingStore() {}',
    +        '',
    +      ].join('
    '),
    +    );
    +    const report = createMigrationReport();
    +
    +    expect(detectTsxProject(root)).toBe(true);
    +    expect(migrateTsxToVpx(root, undefined, report)).toEqual({
    +      changed: true,
    +      dependencyRemoved: true,
    +    });
    +
    +    expect(readJson('package.json')).toEqual({
    +      type: 'module',
    +      scripts: { start: 'vpx index.ts' },
    +      devDependencies: {},
    +    });
    +    expect(report.tsxMigrated).toBe(true);
    +    expect(report.warnings).toEqual([]);
    +  });
     }
    
    diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts
    index 67f18658..ecb1db1b 100644
    --- a/packages/cli/src/migration/migrator/tsx.ts
    +++ b/packages/cli/src/migration/migrator/tsx.ts
    @@ -821,10 +821,15 @@ function fileUsesTsx(content: string, file: string): boolean {
         return jsonUsesTsx(parseJsonc(content), false);
       }
       if (CODE_EXTENSIONS.has(extension)) {
    -    return TSX_CODE_REFERENCE.test(content);
    +    // Comments and JSDoc/Markdown fences, such as ```` ```tsx ````, are not real
    +    // uses of tsx.
    +    return TSX_CODE_REFERENCE.test(stripComments(content));
       }
       // An extensionless file can be JavaScript, such as an executable, as well as shell.
    -  return TSX_TEXT_REFERENCE.test(content) || (extension === '' && TSX_CODE_REFERENCE.test(content));
    +  return (
    +    TSX_TEXT_REFERENCE.test(content) ||
    +    (extension === '' && TSX_CODE_REFERENCE.test(stripComments(content)))
    +  );
     }

    Validation

    1. Unit regression test passes:

      pnpm -F vite-plus exec vitest run src/migration/__tests__/tsx.spec.ts
      # 151 passed
      
    2. Original reproduction no longer keeps tsx:

      node /home/runner/work/vite-plus/vite-plus/packages/cli/dist/bin.js migrate --no-interactive
      # Output:
      # • tsx scripts migrated to `vpx`
      # No warning; package.json devDependencies no longer contains tsx.
    3. Control case (```ts fence) still has tsx removed.

    4. Formatting: vp fmt packages/cli/src/migration/migrator/tsx.ts packages/cli/src/migration/__tests__/tsx.spec.ts --check passes.

    5. Lint: vp lint packages/cli/src/migration/migrator/tsx.ts packages/cli/src/migration/__tests__/tsx.spec.ts reports 0 warnings/errors.

    6. Full vp check: skipped because the repository's docs dependencies (vitepress, @voidzero-dev/vitepress-theme, etc.) are not resolvable on this checkout, producing unrelated TS2307 errors. The changed files themselves pass focused fmt/lint.

    Alternatives considered

    • Tightening only the backtick alternative with a negative lookbehind ((?<!)``) would fix the exact code-fence shape but would still match tsx commands mentioned in // or `/* ... */` comments, so a more general comment-stripping approach was preferred.
    • A brand-new Markdown-code-fence-aware regex was rejected as over-engineered; reusing the existing, battle-tested stripComments helper is minimal and keeps behavior consistent with the script-rewrite checks.

    Commit message

    fix(cli): ignore tsx references inside comments when removing tsx devDependency

    This report was made by an LLM. The analysis may be wrong, and the potential fix might not work, but is intended as a starting point for exploring the issue.

    I've opened a pull request with this fix: #3299

  2. fengmk2 commented on Nov 2, 2026

    @fengmk2
    Member

    Let me fix it.

  3. self-assigned this
    on Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Fields

Priority

None yet

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions