exampleCommands.ts 6.1 KB

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