|
| 1 | +/** |
| 2 | + * Repo fingerprint — the backbone of the per-project profile vector. |
| 3 | + * |
| 4 | + * Free, deterministic, and far more reliable than anything an LLM could extract from |
| 5 | + * a conversation: "this is an 84k-LOC pnpm monorepo on Next 15 with vitest and CI" |
| 6 | + * is a stronger prior about how a user works than any number of remembered facts. |
| 7 | + * |
| 8 | + * PROJECT ID: deliberately reuses resolveProjectId() rather than deriving its own. |
| 9 | + * That formula is already frozen across CLI, Desktop and VS Code and is what the code |
| 10 | + * graph and the X-KlaatAI-Project header key on. A second derivation would give the |
| 11 | + * same repository two identities, and telemetry could never be joined to the graph — |
| 12 | + * silently, and only discoverable much later. |
| 13 | + * |
| 14 | + * PRIVACY: no paths, no repo name, no remote URL, no file names. The branch name is |
| 15 | + * HASHED, not sent: branch names routinely carry ticket ids and feature descriptions. |
| 16 | + * Everything here is a count, a boolean, or a value from a closed vocabulary. |
| 17 | + * |
| 18 | + * COST: bounded. One `git ls-files`, one `git status --porcelain`, one package.json |
| 19 | + * read, and at most SAMPLE_FILES stat calls. Computed once per process. |
| 20 | + */ |
| 21 | + |
| 22 | +import { spawnSync } from "node:child_process"; |
| 23 | +import { createHash } from "node:crypto"; |
| 24 | +import { readFileSync, statSync } from "node:fs"; |
| 25 | +import { join } from "node:path"; |
| 26 | +import { resolveProjectId } from "./project-id.js"; |
| 27 | + |
| 28 | +/** Enough to extrapolate size without walking a large repo. */ |
| 29 | +const SAMPLE_FILES = 150; |
| 30 | +/** Rough bytes-per-line across source code; loc_estimate is explicitly an estimate. */ |
| 31 | +const BYTES_PER_LINE = 38; |
| 32 | + |
| 33 | +const LANG_BY_EXT: Record<string, string> = { |
| 34 | + ts: "typescript", tsx: "typescript", mts: "typescript", cts: "typescript", |
| 35 | + js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript", |
| 36 | + py: "python", go: "go", rs: "rust", java: "java", kt: "kotlin", |
| 37 | + swift: "swift", rb: "ruby", php: "php", cs: "csharp", sql: "sql", |
| 38 | + c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", |
| 39 | + vue: "vue", svelte: "svelte", scala: "scala", dart: "dart", ex: "elixir", |
| 40 | + sh: "shell", bash: "shell", lua: "lua", |
| 41 | +}; |
| 42 | + |
| 43 | +/** Closed vocabulary — package name to reported framework. */ |
| 44 | +const FRAMEWORK_DEPS: Array<[string, string]> = [ |
| 45 | + ["next", "next"], ["react", "react"], ["vue", "vue"], ["svelte", "svelte"], |
| 46 | + ["@angular/core", "angular"], ["nuxt", "nuxt"], ["astro", "astro"], |
| 47 | + ["express", "express"], ["fastify", "fastify"], ["nestjs", "nest"], |
| 48 | + ["@nestjs/core", "nest"], ["electron", "electron"], ["react-native", "react-native"], |
| 49 | + ["tailwindcss", "tailwind"], ["prisma", "prisma"], ["drizzle-orm", "drizzle"], |
| 50 | +]; |
| 51 | + |
| 52 | +const TEST_RUNNER_DEPS = ["vitest", "jest", "mocha", "ava", "playwright", "cypress", "bun:test"]; |
| 53 | + |
| 54 | +export interface RepoFingerprint { |
| 55 | + languages: string[]; |
| 56 | + frameworks: string[]; |
| 57 | + package_manager: string | null; |
| 58 | + test_runner: string | null; |
| 59 | + has_ci: boolean; |
| 60 | + monorepo: boolean; |
| 61 | + file_count: number; |
| 62 | + loc_estimate: number; |
| 63 | + git_branch_hash: string | null; |
| 64 | + dirty_working_tree: boolean; |
| 65 | +} |
| 66 | + |
| 67 | +function git(cwd: string, args: string[]): string | null { |
| 68 | + try { |
| 69 | + const r = spawnSync("git", args, { |
| 70 | + cwd, encoding: "utf-8", timeout: 5_000, maxBuffer: 32 * 1024 * 1024, |
| 71 | + }); |
| 72 | + return r.status === 0 ? r.stdout : null; |
| 73 | + } catch { return null; } |
| 74 | +} |
| 75 | + |
| 76 | +function detectPackageManager(root: string, files: Set<string>): string | null { |
| 77 | + if (files.has("bun.lock") || files.has("bun.lockb")) return "bun"; |
| 78 | + if (files.has("pnpm-lock.yaml")) return "pnpm"; |
| 79 | + if (files.has("yarn.lock")) return "yarn"; |
| 80 | + if (files.has("package-lock.json")) return "npm"; |
| 81 | + if (files.has("poetry.lock")) return "poetry"; |
| 82 | + if (files.has("uv.lock")) return "uv"; |
| 83 | + if (files.has("requirements.txt")) return "pip"; |
| 84 | + if (files.has("Cargo.lock")) return "cargo"; |
| 85 | + if (files.has("go.sum")) return "go"; |
| 86 | + try { statSync(join(root, "package.json")); return "npm"; } catch { return null; } |
| 87 | +} |
| 88 | + |
| 89 | +let _cached: RepoFingerprint | null = null; |
| 90 | + |
| 91 | +export function repoFingerprint(root: string): RepoFingerprint | null { |
| 92 | + if (_cached) return _cached; |
| 93 | + try { |
| 94 | + const listing = git(root, ["ls-files"]); |
| 95 | + if (listing === null) return null; // not a git repo — no fingerprint |
| 96 | + |
| 97 | + const paths = listing.split("\n").filter(Boolean); |
| 98 | + const topLevel = new Set(paths.filter((p) => !p.includes("/"))); |
| 99 | + |
| 100 | + // Language histogram straight off the file list — no extra I/O. |
| 101 | + const langCounts = new Map<string, number>(); |
| 102 | + for (const p of paths) { |
| 103 | + const ext = p.slice(p.lastIndexOf(".") + 1).toLowerCase(); |
| 104 | + const lang = LANG_BY_EXT[ext]; |
| 105 | + if (lang) langCounts.set(lang, (langCounts.get(lang) ?? 0) + 1); |
| 106 | + } |
| 107 | + const languages = [...langCounts.entries()] |
| 108 | + .sort((a, b) => b[1] - a[1]).slice(0, 6).map(([l]) => l); |
| 109 | + |
| 110 | + // Frameworks and test runner from package.json only — a dependency name is a |
| 111 | + // closed-vocabulary lookup, never free text. |
| 112 | + const frameworks: string[] = []; |
| 113 | + let testRunner: string | null = null; |
| 114 | + let workspaces = false; |
| 115 | + try { |
| 116 | + const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8")) as { |
| 117 | + dependencies?: Record<string, string>; |
| 118 | + devDependencies?: Record<string, string>; |
| 119 | + workspaces?: unknown; |
| 120 | + }; |
| 121 | + const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }; |
| 122 | + for (const [dep, label] of FRAMEWORK_DEPS) { |
| 123 | + if (deps[dep] && !frameworks.includes(label)) frameworks.push(label); |
| 124 | + } |
| 125 | + testRunner = TEST_RUNNER_DEPS.find((t) => deps[t]) ?? null; |
| 126 | + workspaces = Boolean(pkg.workspaces); |
| 127 | + } catch { /* no package.json, or unreadable */ } |
| 128 | + |
| 129 | + if (!testRunner) { |
| 130 | + if (paths.some((p) => p.startsWith("tests/") || p.endsWith("_test.go"))) testRunner = "go-test"; |
| 131 | + else if (topLevel.has("pytest.ini") || topLevel.has("tox.ini")) testRunner = "pytest"; |
| 132 | + } |
| 133 | + |
| 134 | + // Size: exact file count, sampled byte total extrapolated to lines. |
| 135 | + const step = Math.max(1, Math.floor(paths.length / SAMPLE_FILES)); |
| 136 | + let sampledBytes = 0; |
| 137 | + let sampled = 0; |
| 138 | + for (let i = 0; i < paths.length; i += step) { |
| 139 | + try { |
| 140 | + sampledBytes += statSync(join(root, paths[i]!)).size; |
| 141 | + sampled++; |
| 142 | + } catch { /* deleted since ls-files, or unreadable */ } |
| 143 | + } |
| 144 | + const avgBytes = sampled > 0 ? sampledBytes / sampled : 0; |
| 145 | + const locEstimate = Math.round((avgBytes * paths.length) / BYTES_PER_LINE); |
| 146 | + |
| 147 | + const branch = git(root, ["rev-parse", "--abbrev-ref", "HEAD"])?.trim() || null; |
| 148 | + const status = git(root, ["status", "--porcelain"]); |
| 149 | + |
| 150 | + _cached = { |
| 151 | + languages, |
| 152 | + frameworks, |
| 153 | + package_manager: detectPackageManager(root, topLevel), |
| 154 | + test_runner: testRunner, |
| 155 | + has_ci: paths.some((p) => p.startsWith(".github/workflows/")) |
| 156 | + || topLevel.has(".gitlab-ci.yml") || topLevel.has("azure-pipelines.yml"), |
| 157 | + monorepo: workspaces |
| 158 | + || topLevel.has("pnpm-workspace.yaml") || topLevel.has("turbo.json") |
| 159 | + || topLevel.has("lerna.json") || topLevel.has("nx.json") |
| 160 | + || paths.filter((p) => p.endsWith("package.json")).length > 1, |
| 161 | + file_count: paths.length, |
| 162 | + loc_estimate: locEstimate, |
| 163 | + // Hashed: branch names carry ticket ids and feature descriptions. |
| 164 | + git_branch_hash: branch |
| 165 | + ? createHash("sha256").update(branch).digest("hex").slice(0, 16) |
| 166 | + : null, |
| 167 | + dirty_working_tree: Boolean(status && status.trim().length > 0), |
| 168 | + }; |
| 169 | + return _cached; |
| 170 | + } catch { |
| 171 | + return null; |
| 172 | + } |
| 173 | +} |
| 174 | + |
| 175 | +/** session.started payload, or null outside a git repo. */ |
| 176 | +export function sessionStartedPayload(root: string): { |
| 177 | + projectId: string | null; |
| 178 | + payload: Record<string, unknown>; |
| 179 | +} | null { |
| 180 | + const fp = repoFingerprint(root); |
| 181 | + if (!fp) return null; |
| 182 | + let projectId: string | null = null; |
| 183 | + try { projectId = resolveProjectId(root)?.id ?? null; } catch { /* ignore */ } |
| 184 | + return { projectId, payload: { project_fingerprint: fp } }; |
| 185 | +} |
| 186 | + |
| 187 | +/** Test seam. */ |
| 188 | +export function _resetFingerprintCache(): void { _cached = null; } |
0 commit comments