Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/lineage

src/classify.ts

439 lines · 9.1 KB · TypeScript
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<string, string> = {
	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<string>
}

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()
}