/ src / utils / exampleCommands.ts
exampleCommands.ts
  1  import memoize from 'lodash-es/memoize.js'
  2  import sample from 'lodash-es/sample.js'
  3  import { getCwd } from '../utils/cwd.js'
  4  import { getCurrentProjectConfig, saveCurrentProjectConfig } from './config.js'
  5  import { env } from './env.js'
  6  import { execFileNoThrowWithCwd } from './execFileNoThrow.js'
  7  import { getIsGit, gitExe } from './git.js'
  8  import { logError } from './log.js'
  9  import { getGitEmail } from './user.js'
 10  
 11  // Patterns that mark a file as non-core (auto-generated, dependency, or config).
 12  // Used to filter example-command filename suggestions deterministically
 13  // instead of shelling out to Haiku.
 14  const NON_CORE_PATTERNS = [
 15    // lock / dependency manifests
 16    /(?:^|\/)(?:package-lock\.json|yarn\.lock|bun\.lock|bun\.lockb|pnpm-lock\.yaml|Pipfile\.lock|poetry\.lock|Cargo\.lock|Gemfile\.lock|go\.sum|composer\.lock|uv\.lock)$/,
 17    // generated / build artifacts
 18    /\.generated\./,
 19    /(?:^|\/)(?:dist|build|out|target|node_modules|\.next|__pycache__)\//,
 20    /\.(?:min\.js|min\.css|map|pyc|pyo)$/,
 21    // data / docs / config extensions (not "write a test for" material)
 22    /\.(?:json|ya?ml|toml|xml|ini|cfg|conf|env|lock|txt|md|mdx|rst|csv|log|svg)$/i,
 23    // configuration / metadata
 24    /(?:^|\/)\.?(?:eslintrc|prettierrc|babelrc|editorconfig|gitignore|gitattributes|dockerignore|npmrc)/,
 25    /(?:^|\/)(?:tsconfig|jsconfig|biome|vitest\.config|jest\.config|webpack\.config|vite\.config|rollup\.config)\.[a-z]+$/,
 26    /(?:^|\/)\.(?:github|vscode|idea|claude)\//,
 27    // docs / changelogs (not "how does X work" material)
 28    /(?:^|\/)(?:CHANGELOG|LICENSE|CONTRIBUTING|CODEOWNERS|README)(?:\.[a-z]+)?$/i,
 29  ]
 30  
 31  function isCoreFile(path: string): boolean {
 32    return !NON_CORE_PATTERNS.some(p => p.test(path))
 33  }
 34  
 35  /**
 36   * Counts occurrences of items in an array and returns the top N items
 37   * sorted by count in descending order, formatted as a string.
 38   */
 39  export function countAndSortItems(items: string[], topN: number = 20): string {
 40    const counts = new Map<string, number>()
 41    for (const item of items) {
 42      counts.set(item, (counts.get(item) || 0) + 1)
 43    }
 44    return Array.from(counts.entries())
 45      .sort((a, b) => b[1] - a[1])
 46      .slice(0, topN)
 47      .map(([item, count]) => `${count.toString().padStart(6)} ${item}`)
 48      .join('\n')
 49  }
 50  
 51  /**
 52   * Picks up to `want` basenames from a frequency-sorted list of paths,
 53   * skipping non-core files and spreading across different directories.
 54   * Returns empty array if fewer than `want` core files are available.
 55   */
 56  export function pickDiverseCoreFiles(
 57    sortedPaths: string[],
 58    want: number,
 59  ): string[] {
 60    const picked: string[] = []
 61    const seenBasenames = new Set<string>()
 62    const dirTally = new Map<string, number>()
 63  
 64    // Greedy: on each pass allow +1 file per directory. Keeps the
 65    // top-5 from collapsing into a single hot folder while still
 66    // letting a dominant folder contribute multiple files if the
 67    // repo is narrow.
 68    for (let cap = 1; picked.length < want && cap <= want; cap++) {
 69      for (const p of sortedPaths) {
 70        if (picked.length >= want) break
 71        if (!isCoreFile(p)) continue
 72        const lastSep = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
 73        const base = lastSep >= 0 ? p.slice(lastSep + 1) : p
 74        if (!base || seenBasenames.has(base)) continue
 75        const dir = lastSep >= 0 ? p.slice(0, lastSep) : '.'
 76        if ((dirTally.get(dir) ?? 0) >= cap) continue
 77        picked.push(base)
 78        seenBasenames.add(base)
 79        dirTally.set(dir, (dirTally.get(dir) ?? 0) + 1)
 80      }
 81    }
 82  
 83    return picked.length >= want ? picked : []
 84  }
 85  
 86  async function getFrequentlyModifiedFiles(): Promise<string[]> {
 87    if (process.env.NODE_ENV === 'test') return []
 88    if (env.platform === 'win32') return []
 89    if (!(await getIsGit())) return []
 90  
 91    try {
 92      // Collect frequently-modified files, preferring the user's own commits.
 93      const userEmail = await getGitEmail()
 94  
 95      const logArgs = [
 96        'log',
 97        '-n',
 98        '1000',
 99        '--pretty=format:',
100        '--name-only',
101        '--diff-filter=M',
102      ]
103  
104      const counts = new Map<string, number>()
105      const tallyInto = (stdout: string) => {
106        for (const line of stdout.split('\n')) {
107          const f = line.trim()
108          if (f) counts.set(f, (counts.get(f) ?? 0) + 1)
109        }
110      }
111  
112      if (userEmail) {
113        const { stdout } = await execFileNoThrowWithCwd(
114          'git',
115          [...logArgs, `--author=${userEmail}`],
116          { cwd: getCwd() },
117        )
118        tallyInto(stdout)
119      }
120  
121      // Fall back to all authors if the user's own history is thin.
122      if (counts.size < 10) {
123        const { stdout } = await execFileNoThrowWithCwd(gitExe(), logArgs, {
124          cwd: getCwd(),
125        })
126        tallyInto(stdout)
127      }
128  
129      const sorted = Array.from(counts.entries())
130        .sort((a, b) => b[1] - a[1])
131        .map(([p]) => p)
132  
133      return pickDiverseCoreFiles(sorted, 5)
134    } catch (err) {
135      logError(err as Error)
136      return []
137    }
138  }
139  
140  const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000
141  
142  export const getExampleCommandFromCache = memoize(() => {
143    const projectConfig = getCurrentProjectConfig()
144    const frequentFile = projectConfig.exampleFiles?.length
145      ? sample(projectConfig.exampleFiles)
146      : '<filepath>'
147  
148    const commands = [
149      'fix lint errors',
150      'fix typecheck errors',
151      `how does ${frequentFile} work?`,
152      `refactor ${frequentFile}`,
153      'how do I log an error?',
154      `edit ${frequentFile} to...`,
155      `write a test for ${frequentFile}`,
156      'create a util logging.py that...',
157    ]
158  
159    return `Try "${sample(commands)}"`
160  })
161  
162  export const refreshExampleCommands = memoize(async (): Promise<void> => {
163    const projectConfig = getCurrentProjectConfig()
164    const now = Date.now()
165    const lastGenerated = projectConfig.exampleFilesGeneratedAt ?? 0
166  
167    // Regenerate examples if they're over a week old
168    if (now - lastGenerated > ONE_WEEK_IN_MS) {
169      projectConfig.exampleFiles = []
170    }
171  
172    // If no example files cached, kickstart fetch in background
173    if (!projectConfig.exampleFiles?.length) {
174      void getFrequentlyModifiedFiles().then(files => {
175        if (files.length) {
176          saveCurrentProjectConfig(current => ({
177            ...current,
178            exampleFiles: files,
179            exampleFilesGeneratedAt: Date.now(),
180          }))
181        }
182      })
183    }
184  })