CtrlK
BlogDocsLog inGet started
Tessl Logo

pleaseai/please-plugins

Auto-detect project dependencies and recommend matching Claude Code plugins from the pleaseai marketplace

68

Quality

85%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files

check-dependencies.test.tshooks/

import { describe, expect, test } from 'bun:test'
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import {
  buildOutput,
  collectAllDependencies,
  detectPackages,
  detectTooling,
  extractPackagesFromCommand,
  filterInstalledPlugins,
  resolveWorkspacePackages,
  scanForSetup,
  type DetectedPlugin,
  type PluginMapping,
  type SetupOutput,
  type ToolingMapping,
  PLUGIN_MAPPINGS,
  TOOLING_MAPPINGS,
} from './check-dependencies'

describe('PLUGIN_MAPPINGS', () => {
  test('contains expected mappings', () => {
    expect(PLUGIN_MAPPINGS.length).toBeGreaterThan(0)

    const pluginNames = PLUGIN_MAPPINGS.map(m => m.pluginName)
    expect(pluginNames).toContain('nuxt-ui')
    expect(pluginNames).toContain('nuxt-seo')
    expect(pluginNames).toContain('pinia')
    expect(pluginNames).toContain('vueuse')
    expect(pluginNames).toContain('vitest')
    expect(pluginNames).toContain('prisma')
    expect(pluginNames).toContain('supabase')
  })

  test('each mapping has packages and pluginName', () => {
    for (const mapping of PLUGIN_MAPPINGS) {
      expect(mapping.packages.length).toBeGreaterThan(0)
      expect(mapping.pluginName).toBeTruthy()
    }
  })
})

describe('detectPackages', () => {
  test('detects @nuxt/ui in dependencies', () => {
    const pkg = { dependencies: { '@nuxt/ui': '^3.0.0' } }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'nuxt-ui' }))
  })

  test('detects @nuxt/ui in devDependencies', () => {
    const pkg = { devDependencies: { '@nuxt/ui': '^3.0.0' } }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'nuxt-ui' }))
  })

  test('detects @nuxtjs/seo and maps to nuxt-seo', () => {
    const pkg = { dependencies: { '@nuxtjs/seo': '^2.0.0' } }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'nuxt-seo' }))
  })

  test('detects pinia and maps to pinia plugin', () => {
    const pkg = { dependencies: { 'pinia': '^2.0.0' } }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'pinia' }))
  })

  test('detects @pinia/nuxt and maps to pinia plugin', () => {
    const pkg = { dependencies: { '@pinia/nuxt': '^0.5.0' } }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'pinia' }))
  })

  test('detects @vueuse/nuxt and maps to vueuse plugin', () => {
    const pkg = { dependencies: { '@vueuse/nuxt': '^10.0.0' } }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'vueuse' }))
  })

  test('detects @vueuse/core and maps to vueuse plugin', () => {
    const pkg = { dependencies: { '@vueuse/core': '^10.0.0' } }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'vueuse' }))
  })

  test('detects vitest in devDependencies', () => {
    const pkg = { devDependencies: { 'vitest': '^2.0.0' } }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'vitest' }))
  })

  test('detects @prisma/client and maps to prisma plugin', () => {
    const pkg = { dependencies: { '@prisma/client': '^5.0.0' } }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'prisma' }))
  })

  test('detects multiple packages and returns all recommendations', () => {
    const pkg = {
      dependencies: {
        '@nuxt/ui': '^3.0.0',
        'pinia': '^2.0.0',
        '@prisma/client': '^5.0.0',
      },
    }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    const pluginNames = result.map(r => r.pluginName)
    expect(pluginNames).toContain('nuxt-ui')
    expect(pluginNames).toContain('pinia')
    expect(pluginNames).toContain('prisma')
  })

  test('returns unique results when multiple packages match same plugin', () => {
    const pkg = {
      dependencies: {
        'pinia': '^2.0.0',
        '@pinia/nuxt': '^0.5.0',
      },
    }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    const piniaResults = result.filter(r => r.pluginName === 'pinia')
    expect(piniaResults).toHaveLength(1)
  })

  test('returns empty array when no matching packages', () => {
    const pkg = { dependencies: { 'express': '^4.0.0' } }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    expect(result).toHaveLength(0)
  })

  test('returns empty array for empty package.json', () => {
    const result = detectPackages({}, PLUGIN_MAPPINGS)
    expect(result).toHaveLength(0)
  })

  test('handles package.json with no dependencies', () => {
    const pkg = { name: 'test', version: '1.0.0' }
    const result = detectPackages(pkg, PLUGIN_MAPPINGS)
    expect(result).toHaveLength(0)
  })
})

describe('detectTooling', () => {
  const mappings: ToolingMapping[] = [
    { indicators: { files: ['pnpm-lock.yaml'], packageManager: 'pnpm' }, pluginName: 'pnpm' },
    { indicators: { files: ['turbo.json'], packageManager: null }, pluginName: 'turborepo' },
  ]

  test('detects pnpm from pnpm-lock.yaml', () => {
    const dir = mkdtempSync(join(tmpdir(), 'rec-'))
    writeFileSync(join(dir, 'pnpm-lock.yaml'), '')
    const result = detectTooling(dir, null, mappings)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'pnpm' }))
  })

  test('detects pnpm from packageManager field', () => {
    const dir = mkdtempSync(join(tmpdir(), 'rec-'))
    const pkg = { packageManager: 'pnpm@9.0.0' }
    const result = detectTooling(dir, pkg, mappings)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'pnpm' }))
  })

  test('detects turborepo from turbo.json', () => {
    const dir = mkdtempSync(join(tmpdir(), 'rec-'))
    writeFileSync(join(dir, 'turbo.json'), '{}')
    const result = detectTooling(dir, null, mappings)
    expect(result).toContainEqual(expect.objectContaining({ pluginName: 'turborepo' }))
  })

  test('returns empty when no tooling indicators found', () => {
    const dir = mkdtempSync(join(tmpdir(), 'rec-'))
    const result = detectTooling(dir, null, mappings)
    expect(result).toHaveLength(0)
  })

  test('deduplicates when both lock file and packageManager match', () => {
    const dir = mkdtempSync(join(tmpdir(), 'rec-'))
    writeFileSync(join(dir, 'pnpm-lock.yaml'), '')
    const pkg = { packageManager: 'pnpm@9.0.0' }
    const result = detectTooling(dir, pkg, mappings)
    const pnpmResults = result.filter(r => r.pluginName === 'pnpm')
    expect(pnpmResults).toHaveLength(1)
  })

  test('TOOLING_MAPPINGS contains expected entries', () => {
    const pluginNames = TOOLING_MAPPINGS.map(m => m.pluginName)
    expect(pluginNames).toContain('pnpm')
    expect(pluginNames).toContain('turborepo')
  })
})

describe('filterInstalledPlugins', () => {
  const mappings: PluginMapping[] = [
    { packages: ['@nuxt/ui'], pluginName: 'nuxt-ui' },
    { packages: ['pinia'], pluginName: 'pinia' },
    { packages: ['vitest'], pluginName: 'vitest' },
  ]

  test('removes plugins found in enabledPlugins', () => {
    const enabledPlugins = {
      'nuxt-ui@pleaseai': true,
      'pinia@pleaseai': false,
    }
    const result = filterInstalledPlugins(mappings, enabledPlugins)
    expect(result).toHaveLength(1)
    expect(result[0].pluginName).toBe('vitest')
  })

  test('keeps plugins not in enabledPlugins', () => {
    const result = filterInstalledPlugins(mappings, {})
    expect(result).toHaveLength(3)
  })

  test('removes plugins regardless of enabled/disabled state', () => {
    const enabledPlugins = {
      'nuxt-ui@pleaseai': false,
    }
    const result = filterInstalledPlugins(mappings, enabledPlugins)
    expect(result).toHaveLength(2)
  })

  test('handles undefined enabledPlugins', () => {
    const result = filterInstalledPlugins(mappings, undefined)
    expect(result).toHaveLength(3)
  })
})

describe('extractPackagesFromCommand', () => {
  test('extracts from bun add', () => {
    expect(extractPackagesFromCommand('bun add @nuxt/ui')).toEqual(['@nuxt/ui'])
  })

  test('extracts from npm install', () => {
    expect(extractPackagesFromCommand('npm install pinia')).toEqual(['pinia'])
  })

  test('extracts from npm add', () => {
    expect(extractPackagesFromCommand('npm add @vueuse/nuxt')).toEqual(['@vueuse/nuxt'])
  })

  test('extracts from pnpm add', () => {
    expect(extractPackagesFromCommand('pnpm add @prisma/client')).toEqual(['@prisma/client'])
  })

  test('extracts from pnpm install', () => {
    expect(extractPackagesFromCommand('pnpm install vitest')).toEqual(['vitest'])
  })

  test('extracts multiple packages', () => {
    expect(extractPackagesFromCommand('bun add @nuxt/ui pinia vitest')).toEqual(['@nuxt/ui', 'pinia', 'vitest'])
  })

  test('filters out flags', () => {
    expect(extractPackagesFromCommand('bun add -d vitest')).toEqual(['vitest'])
    expect(extractPackagesFromCommand('npm install --save-dev vitest')).toEqual(['vitest'])
    expect(extractPackagesFromCommand('pnpm add -D @prisma/client')).toEqual(['@prisma/client'])
  })

  test('returns empty for non-install commands', () => {
    expect(extractPackagesFromCommand('bun run dev')).toEqual([])
    expect(extractPackagesFromCommand('npm test')).toEqual([])
    expect(extractPackagesFromCommand('ls -la')).toEqual([])
  })

  test('returns empty for install without packages (lockfile install)', () => {
    expect(extractPackagesFromCommand('npm install')).toEqual([])
    expect(extractPackagesFromCommand('pnpm install')).toEqual([])
    expect(extractPackagesFromCommand('bun install')).toEqual([])
  })

  test('strips version/tag suffixes from package names', () => {
    expect(extractPackagesFromCommand('bun add pinia@latest')).toEqual(['pinia'])
    expect(extractPackagesFromCommand('npm install vitest@^2.0.0')).toEqual(['vitest'])
    expect(extractPackagesFromCommand('pnpm add @prisma/client@latest')).toEqual(['@prisma/client'])
    expect(extractPackagesFromCommand('bun add @nuxt/ui@3.0.0')).toEqual(['@nuxt/ui'])
  })
})

describe('buildOutput', () => {
  test('builds valid SessionStart output', () => {
    const mappings: PluginMapping[] = [
      { packages: ['@nuxt/ui'], pluginName: 'nuxt-ui' },
    ]
    const output = buildOutput(mappings, 'SessionStart')
    expect(output).toHaveProperty('hookSpecificOutput')
    expect(output.hookSpecificOutput.hookEventName).toBe('SessionStart')
    expect(output.hookSpecificOutput.additionalContext).toContain('nuxt-ui')
    expect(output.systemMessage).toContain('/plugin install nuxt-ui@pleaseai')
  })

  test('builds valid PostToolUse output', () => {
    const mappings: PluginMapping[] = [
      { packages: ['pinia'], pluginName: 'pinia' },
    ]
    const output = buildOutput(mappings, 'PostToolUse')
    expect(output.hookSpecificOutput.hookEventName).toBe('PostToolUse')
    expect(output.systemMessage).toContain('/plugin install pinia@pleaseai')
  })

  test('includes all plugin names in output', () => {
    const mappings: PluginMapping[] = [
      { packages: ['@nuxt/ui'], pluginName: 'nuxt-ui' },
      { packages: ['pinia'], pluginName: 'pinia' },
      { packages: ['vitest'], pluginName: 'vitest' },
    ]
    const output = buildOutput(mappings, 'SessionStart')
    expect(output.systemMessage).toContain('nuxt-ui@pleaseai')
    expect(output.systemMessage).toContain('pinia@pleaseai')
    expect(output.systemMessage).toContain('vitest@pleaseai')
  })
})

describe('scanForSetup', () => {
  test('detects packages and returns DetectedPlugin with source', () => {
    const dir = mkdtempSync(join(tmpdir(), 'setup-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({
      dependencies: { '@nuxt/ui': '^3.0.0', 'pinia': '^2.0.0' },
    }))
    const result = scanForSetup(dir)
    expect(result.detected).toContainEqual({ pluginName: 'nuxt-ui', source: '@nuxt/ui' })
    expect(result.detected).toContainEqual({ pluginName: 'pinia', source: 'pinia' })
  })

  test('detects tooling indicators with source', () => {
    const dir = mkdtempSync(join(tmpdir(), 'setup-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'test' }))
    writeFileSync(join(dir, 'turbo.json'), '{}')
    const result = scanForSetup(dir)
    expect(result.detected).toContainEqual({ pluginName: 'turborepo', source: 'turbo.json' })
  })

  test('detects packageManager field as source', () => {
    const dir = mkdtempSync(join(tmpdir(), 'setup-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({
      name: 'test',
      packageManager: 'pnpm@9.0.0',
    }))
    const result = scanForSetup(dir)
    expect(result.detected).toContainEqual({ pluginName: 'pnpm', source: 'packageManager:pnpm@9.0.0' })
  })

  test('returns empty detected when no package.json exists', () => {
    const dir = mkdtempSync(join(tmpdir(), 'setup-'))
    const result = scanForSetup(dir)
    expect(result.detected).toHaveLength(0)
    expect(result.installed).toHaveLength(0)
  })

  test('returns empty detected when no matching packages', () => {
    const dir = mkdtempSync(join(tmpdir(), 'setup-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({
      dependencies: { 'express': '^4.0.0' },
    }))
    const result = scanForSetup(dir)
    expect(result.detected).toHaveLength(0)
  })

  test('separates installed plugins from detected', () => {
    const dir = mkdtempSync(join(tmpdir(), 'setup-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({
      dependencies: { '@nuxt/ui': '^3.0.0', 'pinia': '^2.0.0' },
    }))
    // Simulate nuxt-ui already installed
    mkdirSync(join(dir, '.claude'), { recursive: true })
    writeFileSync(join(dir, '.claude', 'settings.json'), JSON.stringify({
      enabledPlugins: { 'nuxt-ui@pleaseai': true },
    }))
    const result = scanForSetup(dir)
    expect(result.installed).toContain('nuxt-ui')
    expect(result.detected.map(d => d.pluginName)).not.toContain('nuxt-ui')
    expect(result.detected).toContainEqual({ pluginName: 'pinia', source: 'pinia' })
  })

  test('deduplicates plugins when detected from both packages and tooling', () => {
    const dir = mkdtempSync(join(tmpdir(), 'setup-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({
      name: 'test',
      packageManager: 'pnpm@9.0.0',
    }))
    writeFileSync(join(dir, 'pnpm-lock.yaml'), '')
    const result = scanForSetup(dir)
    const pnpmResults = result.detected.filter(d => d.pluginName === 'pnpm')
    expect(pnpmResults).toHaveLength(1)
  })

  test('detects plugins from workspace packages', () => {
    const dir = mkdtempSync(join(tmpdir(), 'setup-'))
    // Root package.json with workspaces but no matching deps
    writeFileSync(join(dir, 'package.json'), JSON.stringify({
      workspaces: ['apps/*'],
      devDependencies: { 'typescript': '^5.0.0' },
    }))
    // Workspace package with matching deps
    mkdirSync(join(dir, 'apps', 'web'), { recursive: true })
    writeFileSync(join(dir, 'apps', 'web', 'package.json'), JSON.stringify({
      dependencies: { '@nuxt/ui': '^3.0.0' },
    }))
    const result = scanForSetup(dir)
    expect(result.detected).toContainEqual({ pluginName: 'nuxt-ui', source: 'apps/web: @nuxt/ui' })
  })

  test('detects plugins from pnpm workspace packages', () => {
    const dir = mkdtempSync(join(tmpdir(), 'setup-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'root' }))
    writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages:\n  - "packages/*"\n')
    mkdirSync(join(dir, 'packages', 'api'), { recursive: true })
    writeFileSync(join(dir, 'packages', 'api', 'package.json'), JSON.stringify({
      dependencies: { '@prisma/client': '^5.0.0' },
    }))
    const result = scanForSetup(dir)
    expect(result.detected).toContainEqual({ pluginName: 'prisma', source: 'packages/api: @prisma/client' })
  })

  test('merges root and workspace deps without duplicates', () => {
    const dir = mkdtempSync(join(tmpdir(), 'setup-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({
      workspaces: ['apps/*'],
      devDependencies: { 'vitest': '^4.0.0' },
    }))
    mkdirSync(join(dir, 'apps', 'web'), { recursive: true })
    writeFileSync(join(dir, 'apps', 'web', 'package.json'), JSON.stringify({
      dependencies: { '@nuxt/ui': '^3.0.0', 'vitest': '^4.0.0' },
    }))
    const result = scanForSetup(dir)
    const pluginNames = result.detected.map(d => d.pluginName)
    // vitest detected from root (no workspace prefix), nuxt-ui from workspace
    expect(pluginNames).toContain('vitest')
    expect(pluginNames).toContain('nuxt-ui')
    const vitestResult = result.detected.find(d => d.pluginName === 'vitest')
    expect(vitestResult?.source).toBe('vitest') // root, no prefix
  })
})

describe('resolveWorkspacePackages', () => {
  test('resolves npm workspaces glob patterns', () => {
    const dir = mkdtempSync(join(tmpdir(), 'ws-'))
    const pkg = { workspaces: ['apps/*', 'packages/*'] }
    mkdirSync(join(dir, 'apps', 'web'), { recursive: true })
    writeFileSync(join(dir, 'apps', 'web', 'package.json'), '{}')
    mkdirSync(join(dir, 'packages', 'shared'), { recursive: true })
    writeFileSync(join(dir, 'packages', 'shared', 'package.json'), '{}')
    const result = resolveWorkspacePackages(dir, pkg)
    expect(result).toHaveLength(2)
    expect(result).toContainEqual(join(dir, 'apps', 'web'))
    expect(result).toContainEqual(join(dir, 'packages', 'shared'))
  })

  test('resolves pnpm-workspace.yaml patterns', () => {
    const dir = mkdtempSync(join(tmpdir(), 'ws-'))
    writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages:\n  - "apps/*"\n  - "packages/*"\n')
    mkdirSync(join(dir, 'apps', 'web'), { recursive: true })
    writeFileSync(join(dir, 'apps', 'web', 'package.json'), '{}')
    const result = resolveWorkspacePackages(dir, null)
    expect(result).toHaveLength(1)
    expect(result).toContainEqual(join(dir, 'apps', 'web'))
  })

  test('returns empty array when no workspaces configured', () => {
    const dir = mkdtempSync(join(tmpdir(), 'ws-'))
    const result = resolveWorkspacePackages(dir, { name: 'test' })
    expect(result).toHaveLength(0)
  })

  test('skips directories without package.json', () => {
    const dir = mkdtempSync(join(tmpdir(), 'ws-'))
    const pkg = { workspaces: ['apps/*'] }
    mkdirSync(join(dir, 'apps', 'web'), { recursive: true })
    // No package.json in apps/web
    mkdirSync(join(dir, 'apps', 'api'), { recursive: true })
    writeFileSync(join(dir, 'apps', 'api', 'package.json'), '{}')
    const result = resolveWorkspacePackages(dir, pkg)
    expect(result).toHaveLength(1)
    expect(result).toContainEqual(join(dir, 'apps', 'api'))
  })

  test('handles yarn workspaces { packages: [...] } format', () => {
    const dir = mkdtempSync(join(tmpdir(), 'ws-'))
    const pkg = { workspaces: { packages: ['apps/*'] } }
    mkdirSync(join(dir, 'apps', 'web'), { recursive: true })
    writeFileSync(join(dir, 'apps', 'web', 'package.json'), '{}')
    const result = resolveWorkspacePackages(dir, pkg)
    expect(result).toHaveLength(1)
  })

  test('merges npm workspaces and pnpm-workspace.yaml without duplicates', () => {
    const dir = mkdtempSync(join(tmpdir(), 'ws-'))
    const pkg = { workspaces: ['apps/*'] }
    writeFileSync(join(dir, 'pnpm-workspace.yaml'), 'packages:\n  - "apps/*"\n')
    mkdirSync(join(dir, 'apps', 'web'), { recursive: true })
    writeFileSync(join(dir, 'apps', 'web', 'package.json'), '{}')
    const result = resolveWorkspacePackages(dir, pkg)
    // Both sources resolve to the same directory; deduplication ensures exactly one entry
    expect(result).toHaveLength(1)
    expect(result).toContainEqual(join(dir, 'apps', 'web'))
  })

  test('handles exact directory paths (no globs)', () => {
    const dir = mkdtempSync(join(tmpdir(), 'ws-'))
    const pkg = { workspaces: ['tools/cli'] }
    mkdirSync(join(dir, 'tools', 'cli'), { recursive: true })
    writeFileSync(join(dir, 'tools', 'cli', 'package.json'), '{}')
    const result = resolveWorkspacePackages(dir, pkg)
    expect(result).toHaveLength(1)
    expect(result).toContainEqual(join(dir, 'tools', 'cli'))
  })

  test('skips negation patterns', () => {
    const dir = mkdtempSync(join(tmpdir(), 'ws-'))
    const pkg = { workspaces: ['packages/*', '!packages/internal'] }
    mkdirSync(join(dir, 'packages', 'shared'), { recursive: true })
    writeFileSync(join(dir, 'packages', 'shared', 'package.json'), '{}')
    mkdirSync(join(dir, 'packages', 'internal'), { recursive: true })
    writeFileSync(join(dir, 'packages', 'internal', 'package.json'), '{}')
    const result = resolveWorkspacePackages(dir, pkg)
    // Negation patterns are skipped (not filtered), so internal still appears from "packages/*"
    // This is a known limitation — true negation filtering is out of scope
    // Both shared and internal match the "packages/*" glob, so we expect exactly 2 results
    expect(result).toHaveLength(2)
    expect(result).toContainEqual(join(dir, 'packages', 'shared'))
    expect(result).toContainEqual(join(dir, 'packages', 'internal'))
  })
})

describe('collectAllDependencies', () => {
  test('merges root and workspace deps', () => {
    const dir = mkdtempSync(join(tmpdir(), 'collect-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({
      workspaces: ['apps/*'],
      dependencies: { 'express': '^4.0.0' },
    }))
    mkdirSync(join(dir, 'apps', 'web'), { recursive: true })
    writeFileSync(join(dir, 'apps', 'web', 'package.json'), JSON.stringify({
      dependencies: { '@nuxt/ui': '^3.0.0' },
    }))
    const result = collectAllDependencies(dir)
    expect(result.deps).toHaveProperty('express')
    expect(result.deps).toHaveProperty('@nuxt/ui')
    expect(result.sources).not.toHaveProperty('express') // root dep, no source entry
    expect(result.sources['@nuxt/ui']).toBe('apps/web')
  })

  test('root deps take priority over workspace deps', () => {
    const dir = mkdtempSync(join(tmpdir(), 'collect-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({
      workspaces: ['apps/*'],
      devDependencies: { 'vitest': '^4.0.0' },
    }))
    mkdirSync(join(dir, 'apps', 'web'), { recursive: true })
    writeFileSync(join(dir, 'apps', 'web', 'package.json'), JSON.stringify({
      devDependencies: { 'vitest': '^3.0.0' },
    }))
    const result = collectAllDependencies(dir)
    expect(result.deps['vitest']).toBe('^4.0.0') // root version wins
    expect(result.sources).not.toHaveProperty('vitest') // no workspace source for root dep
  })

  test('skips malformed workspace package.json', () => {
    const dir = mkdtempSync(join(tmpdir(), 'collect-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({
      workspaces: ['apps/*'],
    }))
    mkdirSync(join(dir, 'apps', 'broken'), { recursive: true })
    writeFileSync(join(dir, 'apps', 'broken', 'package.json'), 'not valid json')
    mkdirSync(join(dir, 'apps', 'good'), { recursive: true })
    writeFileSync(join(dir, 'apps', 'good', 'package.json'), JSON.stringify({
      dependencies: { 'pinia': '^2.0.0' },
    }))
    const result = collectAllDependencies(dir)
    expect(result.deps).toHaveProperty('pinia')
  })

  test('returns empty deps when no package.json', () => {
    const dir = mkdtempSync(join(tmpdir(), 'collect-'))
    const result = collectAllDependencies(dir)
    expect(Object.keys(result.deps)).toHaveLength(0)
    expect(Object.keys(result.sources)).toHaveLength(0)
  })

  test('works with non-monorepo (no workspaces)', () => {
    const dir = mkdtempSync(join(tmpdir(), 'collect-'))
    writeFileSync(join(dir, 'package.json'), JSON.stringify({
      dependencies: { 'express': '^4.0.0' },
    }))
    const result = collectAllDependencies(dir)
    expect(result.deps).toHaveProperty('express')
    expect(Object.keys(result.sources)).toHaveLength(0)
  })
})

README.md

tile.json