export type FileKind = 'source' | 'tests' | 'docs' export type Classification = { countable: boolean language: string kind: FileKind reason: string } const LOCKFILE_NAMES = new Set([ 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'cargo.lock', 'poetry.lock', 'composer.lock', 'gemfile.lock', 'go.sum', 'flake.lock', ]) const BINARY_EXTENSIONS = new Set([ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'bmp', 'tif', 'tiff', 'avif', 'svg', 'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'pdf', 'zip', 'gz', 'tgz', 'bz2', '7z', 'rar', 'wasm', 'exe', 'dll', 'so', 'dylib', 'bin', 'class', 'o', 'a', 'jar', 'apk', 'ipa', 'icns', 'psd', 'fig', 'sketch', ]) const DOC_EXTENSIONS = new Set([ 'md', 'mdx', 'rst', 'adoc', 'asciidoc', 'txt', ]) const DOC_BASENAMES = new Set([ 'readme', 'changelog', 'changes', 'contributing', 'code_of_conduct', 'security', 'authors', 'license', 'licence', 'copying', 'notice', 'patent', ]) const LANGUAGE_BY_EXTENSION: Record = { ts: 'TypeScript', tsx: 'TypeScript', mts: 'TypeScript', cts: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', css: 'CSS', scss: 'SCSS', sass: 'Sass', less: 'Less', html: 'HTML', htm: 'HTML', json: 'JSON', jsonc: 'JSON', yml: 'YAML', yaml: 'YAML', toml: 'TOML', sql: 'SQL', graphql: 'GraphQL', gql: 'GraphQL', sh: 'Shell', bash: 'Shell', zsh: 'Shell', fish: 'Shell', ps1: 'PowerShell', py: 'Python', rb: 'Ruby', go: 'Go', rs: 'Rust', java: 'Java', kt: 'Kotlin', kts: 'Kotlin', swift: 'Swift', c: 'C', h: 'C', cc: 'C++', cpp: 'C++', cxx: 'C++', hpp: 'C++', cs: 'C#', php: 'PHP', lua: 'Lua', r: 'R', pl: 'Perl', scala: 'Scala', clj: 'Clojure', ex: 'Elixir', exs: 'Elixir', erl: 'Erlang', hs: 'Haskell', ml: 'OCaml', vue: 'Vue', svelte: 'Svelte', astro: 'Astro', tf: 'HCL', hcl: 'HCL', nix: 'Nix', dockerfile: 'Dockerfile', makefile: 'Makefile', mk: 'Makefile', cmake: 'CMake', gradle: 'Gradle', proto: 'Protocol Buffers', wasm: 'WebAssembly', wat: 'WebAssembly', } const GENERATED_PATH_RULES: Array<{ reason: string; test: (path: string) => boolean }> = [ { reason: 'lockfile', test: (path) => LOCKFILE_NAMES.has(basename(path).toLowerCase()), }, { reason: 'generated-directory', test: (path) => /(?:^|\/)(?:generated|__generated__|codegen|\.generated)(?:\/|$)/i.test( path, ), }, { reason: 'generated-suffix', test: (path) => /\.(?:generated|gen)\.[^/]+$/i.test(path) || /\.gen\.[cm]?[jt]sx?$/i.test(path) || /routeTree\.gen\.[cm]?[jt]sx?$/i.test(path), }, { reason: 'generated-declaration-stub', test: (path) => /(?:^|\/)generated-[^/]+\.d\.ts$/i.test(path), }, { reason: 'generated-prefixed-module', test: (path) => { const name = basename(path) if (name === 'generated-username.ts') return false return /^generated-[^/]+\.(?:[cm]?[jt]sx?)$/i.test(name) }, }, { reason: 'wrangler-types', test: (path) => /(?:^|\/)worker-configuration\.d\.ts$/i.test(path), }, { reason: 'wrangler-generated-config', test: (path) => /wrangler(?:-[^/]+)?\.generated\.json$/i.test(path), }, { reason: 'minified-or-sourcemap', test: (path) => /\.min\.(?:js|css)$/i.test(path) || /\.map$/i.test(path), }, { reason: 'snapshot-artifact', test: (path) => /\.snap$/i.test(path) || /(?:^|\/)__snapshots__\//i.test(path), }, { reason: 'build-output', test: (path) => /(?:^|\/)(?:dist|dist-ssr|build|out|\.next|coverage|storybook-static|playwright-report|test-results)(?:\/|$)/i.test( path, ), }, { reason: 'vendor-or-deps', test: (path) => /(?:^|\/)(?:node_modules|vendor|third_party|\.nx|\.wrangler)(?:\/|$)/i.test( path, ), }, { reason: 'generated-client-bundle', test: (path) => /(?:^|\/)(?:client-entry\.js|client-manifest\.json)$/i.test(path), }, ] const GENERATED_CONTENT_MARKERS = [ /\b@generated\b/, /Generated by Wrangler/i, /Code generated by /i, /This file (?:was|is) (?:automatically )?generated/i, /auto-generated (?:file|by)/i, /DO NOT EDIT\b/, /do not edit (?:this|by hand)/i, /eslint-disable \*\/\s*\n\/\/ Generated by/i, ] export type IgnoreIndex = { gitignore: string[] linguistGenerated: string[] knownGeneratedPaths: Set } export function emptyIgnoreIndex(): IgnoreIndex { return { gitignore: [], linguistGenerated: [], knownGeneratedPaths: new Set(), } } export function parseGitignore(text: string): string[] { return text .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')) } export function parseLinguistGenerated(gitattributes: string): string[] { const patterns: string[] = [] for (const raw of gitattributes.split(/\r?\n/)) { const line = raw.trim() if (!line || line.startsWith('#')) continue if (!/\blinguist-generated(?:=true)?\b/.test(line)) continue if (/\blinguist-generated=false\b/.test(line)) continue const pattern = line.split(/\s+/)[0] if (pattern) patterns.push(pattern) } return patterns } export function classifyPath( filename: string, options: { patch?: string | null ignore?: IgnoreIndex } = {}, ): Classification { const path = filename.replaceAll('\\', '/') const ignore = options.ignore ?? emptyIgnoreIndex() if (ignore.knownGeneratedPaths.has(path)) { return skip('cached-generated-path') } if (matchAnyGitPattern(path, ignore.linguistGenerated)) { return skip('gitattributes-linguist-generated') } for (const rule of GENERATED_PATH_RULES) { if (rule.test(path)) return skip(rule.reason) } if (patchLooksGenerated(options.patch)) { return skip('generated-content-marker') } const ext = extension(path) if (BINARY_EXTENSIONS.has(ext)) return skip('binary') const language = languageFor(path, ext) if (!language) return skip('non-code') return { countable: true, language, kind: kindFor(path, ext), reason: 'countable', } } export function languageFor(path: string, ext = extension(path)): string | null { const base = basename(path).toLowerCase() if (base === 'dockerfile') return 'Dockerfile' if (base === 'makefile') return 'Makefile' if (DOC_EXTENSIONS.has(ext) || DOC_BASENAMES.has(stem(base))) { return 'Markdown' } return LANGUAGE_BY_EXTENSION[ext] ?? null } export function kindFor(path: string, ext = extension(path)): FileKind { const lower = path.toLowerCase() const base = basename(lower) if ( DOC_EXTENSIONS.has(ext) || DOC_BASENAMES.has(stem(base)) || /(?:^|\/)docs?\//.test(lower) ) { return 'docs' } if ( /\.(?:test|spec|node\.test|node\.spec)\.[^./]+$/i.test(path) || /(?:^|\/)(?:__tests__|tests?|e2e|spec)(?:\/|$)/i.test(lower) ) { return 'tests' } return 'source' } export function patchLooksGenerated(patch?: string | null): boolean { if (!patch) return false const head = addedPatchHead(patch, 40) return GENERATED_CONTENT_MARKERS.some((marker) => marker.test(head)) } export function addedPatchHead(patch: string, lineLimit: number): string { const lines: string[] = [] for (const line of patch.split(/\r?\n/)) { if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@')) { continue } if (line.startsWith('+')) { lines.push(line.slice(1)) if (lines.length >= lineLimit) break } } return lines.join('\n') } export function matchAnyGitPattern(path: string, patterns: string[]): boolean { return patterns.some((pattern) => matchGitPattern(path, pattern)) } export function matchGitPattern(path: string, rawPattern: string): boolean { let pattern = rawPattern.trim() if (!pattern) return false let negated = false if (pattern.startsWith('!')) { negated = true pattern = pattern.slice(1) } if (pattern.startsWith('/')) pattern = pattern.slice(1) const anchored = pattern.includes('/') const regex = gitPatternToRegExp(pattern, anchored) const matched = regex.test(path) || regex.test(basename(path)) return negated ? !matched : matched } function gitPatternToRegExp(pattern: string, anchored: boolean): RegExp { let source = '' for (let i = 0; i < pattern.length; i += 1) { const char = pattern[i] if (char === '*' && pattern[i + 1] === '*') { const next = pattern[i + 2] if (next === '/') { source += '(?:.*/)?' i += 2 } else { source += '.*' i += 1 } continue } if (char === '*') { source += '[^/]*' continue } if (char === '?') { source += '[^/]' continue } if ('+.^$()[]{}|\\'.includes(char)) { source += `\\${char}` continue } source += char } if (pattern.endsWith('/')) { source += '.*' } const prefix = anchored ? '^' : '(?:^|/)' return new RegExp(`${prefix}${source}$`, 'i') } function skip(reason: string): Classification { return { countable: false, language: '', kind: 'source', reason } } function basename(path: string): string { const parts = path.split('/') return parts[parts.length - 1] ?? path } function stem(filename: string): string { return filename.replace(/\.[^.]+$/, '') } function extension(path: string): string { const base = basename(path) if (base.startsWith('.') && !base.slice(1).includes('.')) return '' const dot = base.lastIndexOf('.') return dot === -1 ? '' : base.slice(dot + 1).toLowerCase() }