cartography.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. #!/usr/bin/env bun
  2. import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
  3. import { join, relative, resolve } from 'node:path';
  4. import { createMD5, md5 } from 'hash-wasm';
  5. import ignore from 'ignore';
  6. interface FileEntry {
  7. p: string;
  8. h: string;
  9. }
  10. interface CodemapData {
  11. h: string;
  12. f: FileEntry[];
  13. }
  14. interface RootCodemapData {
  15. folders: Record<string, CodemapData>;
  16. }
  17. const DEFAULT_IGNORE = [
  18. 'node_modules',
  19. '.git',
  20. 'dist',
  21. 'build',
  22. '.next',
  23. 'coverage',
  24. '.turbo',
  25. 'out',
  26. '*.log',
  27. '.DS_Store',
  28. ];
  29. function parseGitignore(folder: string, extraIgnores: string[]): ignore.Ignore {
  30. const gitignorePath = join(folder, '.gitignore');
  31. const ig = ignore();
  32. if (extraIgnores.length > 0) {
  33. ig.add(extraIgnores);
  34. }
  35. if (existsSync(gitignorePath)) {
  36. const content = readFileSync(gitignorePath, 'utf-8');
  37. ig.add(content.split('\n'));
  38. }
  39. return ig;
  40. }
  41. function shouldIgnore(relPath: string, ignorer: ignore.Ignore): boolean {
  42. if (DEFAULT_IGNORE.some((pattern) => relPath.includes(pattern))) {
  43. return true;
  44. }
  45. return ignorer.ignores(relPath);
  46. }
  47. function getFiles(
  48. folder: string,
  49. extensions: string[],
  50. ignorer: ignore.Ignore,
  51. ): string[] {
  52. const files: string[] = [];
  53. function scan(dir: string, base: string = '') {
  54. const entries = readdirSync(dir, { withFileTypes: true });
  55. for (const entry of entries) {
  56. const fullPath = join(dir, entry.name);
  57. const relPath = base ? join(base, entry.name) : entry.name;
  58. if (shouldIgnore(relPath, ignorer)) {
  59. continue;
  60. }
  61. if (entry.isDirectory()) {
  62. scan(fullPath, relPath);
  63. } else if (entry.isFile()) {
  64. const ext = entry.name.includes('.')
  65. ? '.' + entry.name.split('.').pop()!
  66. : '';
  67. if (extensions.includes(ext)) {
  68. files.push(relPath);
  69. }
  70. }
  71. }
  72. }
  73. scan(folder);
  74. return files.sort((a, b) => a.localeCompare(b));
  75. }
  76. async function calculateHashes(
  77. folder: string,
  78. files: string[],
  79. ): Promise<Map<string, string>> {
  80. const hashes = new Map<string, string>();
  81. for (const file of files) {
  82. const fullPath = join(folder, file);
  83. try {
  84. const content = await Bun.file(fullPath).text();
  85. hashes.set(file, await md5(content));
  86. } catch (error) {
  87. console.error(`Failed to hash ${file}:`, error);
  88. }
  89. }
  90. return hashes;
  91. }
  92. async function calculateFolderHash(
  93. fileHashes: Map<string, string>,
  94. ): Promise<string> {
  95. const hasher = await createMD5();
  96. hasher.init();
  97. const sortedEntries = Array.from(fileHashes.entries()).sort(([a], [b]) =>
  98. a.localeCompare(b),
  99. );
  100. for (const [path, hash] of sortedEntries) {
  101. hasher.update(`${path}:${hash}|`);
  102. }
  103. return hasher.digest();
  104. }
  105. function readRootCodemapData(codemapPath: string): RootCodemapData {
  106. if (!existsSync(codemapPath)) {
  107. return { folders: {} };
  108. }
  109. try {
  110. const content = readFileSync(codemapPath, 'utf-8');
  111. const parsed = JSON.parse(content) as RootCodemapData;
  112. if (!parsed.folders) {
  113. return { folders: {} };
  114. }
  115. return parsed;
  116. } catch {
  117. return { folders: {} };
  118. }
  119. }
  120. function writeRootCodemapData(
  121. codemapPath: string,
  122. data: RootCodemapData,
  123. ): void {
  124. const content = `${JSON.stringify(data, null, 2)}\n`;
  125. writeFileSync(codemapPath, content, 'utf-8');
  126. }
  127. function diffFiles(
  128. currentHashes: Map<string, string>,
  129. previous: CodemapData | null,
  130. ): string[] {
  131. if (!previous) {
  132. return Array.from(currentHashes.keys()).sort((a, b) => a.localeCompare(b));
  133. }
  134. const oldHashes = new Map(previous.f.map((f) => [f.p, f.h]));
  135. const changed = new Set<string>();
  136. for (const [path, hash] of currentHashes) {
  137. if (oldHashes.get(path) !== hash) {
  138. changed.add(path);
  139. }
  140. }
  141. for (const path of oldHashes.keys()) {
  142. if (!currentHashes.has(path)) {
  143. changed.add(path);
  144. }
  145. }
  146. return Array.from(changed).sort((a, b) => a.localeCompare(b));
  147. }
  148. async function updateCodemap(
  149. folder: string,
  150. extensions: string[],
  151. extraIgnores: string[],
  152. ): Promise<{ updated: boolean; fileCount: number; changedFiles: string[] }> {
  153. const ignorer = parseGitignore(folder, extraIgnores);
  154. const files = getFiles(folder, extensions, ignorer);
  155. const fileHashes = await calculateHashes(folder, files);
  156. const folderHash = await calculateFolderHash(fileHashes);
  157. const rootPath = process.cwd();
  158. const codemapPath = join(rootPath, '.codemap.json');
  159. const rootData = readRootCodemapData(codemapPath);
  160. const folderKey = relative(rootPath, folder) || '.';
  161. const existing = rootData.folders[folderKey];
  162. if (existing?.h === folderHash) {
  163. return { updated: false, fileCount: files.length, changedFiles: [] };
  164. }
  165. const changedFiles = diffFiles(fileHashes, existing);
  166. const data: CodemapData = {
  167. h: folderHash,
  168. f: files.map((p) => ({ p, h: fileHashes.get(p)! })),
  169. };
  170. rootData.folders[folderKey] = data;
  171. writeRootCodemapData(codemapPath, rootData);
  172. return { updated: true, fileCount: files.length, changedFiles };
  173. }
  174. async function getChanges(
  175. folder: string,
  176. extensions: string[],
  177. extraIgnores: string[],
  178. ): Promise<{
  179. fileCount: number;
  180. folderHash: string;
  181. changedFiles: string[];
  182. }> {
  183. const ignorer = parseGitignore(folder, extraIgnores);
  184. const files = getFiles(folder, extensions, ignorer);
  185. const fileHashes = await calculateHashes(folder, files);
  186. const folderHash = await calculateFolderHash(fileHashes);
  187. const rootPath = process.cwd();
  188. const codemapPath = join(rootPath, '.codemap.json');
  189. const rootData = readRootCodemapData(codemapPath);
  190. const folderKey = relative(rootPath, folder) || '.';
  191. const existing = rootData.folders[folderKey];
  192. const changedFiles = diffFiles(fileHashes, existing);
  193. return {
  194. fileCount: files.length,
  195. folderHash,
  196. changedFiles,
  197. };
  198. }
  199. async function main() {
  200. const command = process.argv[2];
  201. const folderArg = process.argv[3];
  202. const folder = folderArg ? resolve(folderArg) : process.cwd();
  203. const extArg = process.argv.find((a) => a.startsWith('--extensions'));
  204. const excludeArg = process.argv.find((a) => a.startsWith('--exclude'));
  205. let extensions: string[];
  206. let extraIgnores: string[] = [];
  207. if (extArg) {
  208. const extList = extArg.split('=')[1];
  209. if (extList) {
  210. extensions = extList
  211. .split(',')
  212. .map((e) => '.' + e.trim().replace(/^\./, '')); // 预先计算点号前缀
  213. } else {
  214. extensions = ['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs'];
  215. }
  216. } else {
  217. extensions = ['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs'];
  218. }
  219. if (excludeArg) {
  220. const excludeList = excludeArg.split('=')[1];
  221. if (excludeList) {
  222. extraIgnores = excludeList
  223. .split(',')
  224. .map((e) => e.trim())
  225. .filter(Boolean);
  226. }
  227. }
  228. switch (command) {
  229. case 'scan': {
  230. const ignorer = parseGitignore(folder, extraIgnores);
  231. const files = getFiles(folder, extensions, ignorer);
  232. console.log(JSON.stringify({ folder, files }, null, 2));
  233. break;
  234. }
  235. case 'hash': {
  236. const ignorer = parseGitignore(folder, extraIgnores);
  237. const files = getFiles(folder, extensions, ignorer);
  238. const fileHashes = await calculateHashes(folder, files);
  239. const folderHash = await calculateFolderHash(fileHashes);
  240. console.log(
  241. JSON.stringify(
  242. {
  243. folderHash,
  244. files: Object.fromEntries(fileHashes),
  245. },
  246. null,
  247. 2,
  248. ),
  249. );
  250. break;
  251. }
  252. case 'update': {
  253. const result = await updateCodemap(folder, extensions, extraIgnores);
  254. if (result.updated) {
  255. console.log(
  256. JSON.stringify(
  257. {
  258. updated: true,
  259. folder,
  260. fileCount: result.fileCount,
  261. changedFiles: result.changedFiles,
  262. },
  263. null,
  264. 2,
  265. ),
  266. );
  267. } else {
  268. console.log(
  269. JSON.stringify(
  270. {
  271. updated: false,
  272. folder,
  273. message: 'No changes detected',
  274. },
  275. null,
  276. 2,
  277. ),
  278. );
  279. }
  280. break;
  281. }
  282. case 'changes': {
  283. const result = await getChanges(folder, extensions, extraIgnores);
  284. console.log(
  285. JSON.stringify(
  286. {
  287. folder,
  288. fileCount: result.fileCount,
  289. folderHash: result.folderHash,
  290. changedFiles: result.changedFiles,
  291. hasChanges: result.changedFiles.length > 0,
  292. },
  293. null,
  294. 2,
  295. ),
  296. );
  297. break;
  298. }
  299. default:
  300. console.error(
  301. 'Usage: cartography <scan|hash|update|changes> [folder] [--extensions ts,tsx,js] [--exclude tests,dist]',
  302. );
  303. process.exit(1);
  304. }
  305. }
  306. main().catch((error) => {
  307. console.error('Error:', error);
  308. process.exit(1);
  309. });