source dump of claude code
at main 184 lines 6.2 kB view raw
1import memoize from 'lodash-es/memoize.js' 2import sample from 'lodash-es/sample.js' 3import { getCwd } from '../utils/cwd.js' 4import { getCurrentProjectConfig, saveCurrentProjectConfig } from './config.js' 5import { env } from './env.js' 6import { execFileNoThrowWithCwd } from './execFileNoThrow.js' 7import { getIsGit, gitExe } from './git.js' 8import { logError } from './log.js' 9import { 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. 14const 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 31function 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 */ 39export 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 */ 56export 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 86async 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 140const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000 141 142export 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 162export 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})