cartography.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. #!/usr/bin/env bun
  2. import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
  3. import { join, resolve } from 'node:path';
  4. import { createMD5, md5 } from 'hash-wasm';
  5. import ignore from 'ignore';
  6. import { parse, stringify } from 'yaml';
  7. interface FileEntry {
  8. p: string;
  9. h: string;
  10. }
  11. interface Frontmatter {
  12. h: string;
  13. f: FileEntry[];
  14. }
  15. const DEFAULT_IGNORE = [
  16. 'node_modules',
  17. '.git',
  18. 'dist',
  19. 'build',
  20. '.next',
  21. 'coverage',
  22. '.turbo',
  23. 'out',
  24. '*.log',
  25. '.DS_Store',
  26. ];
  27. function parseGitignore(folder: string): ignore.Ignore {
  28. const gitignorePath = join(folder, '.gitignore');
  29. if (existsSync(gitignorePath)) {
  30. const content = readFileSync(gitignorePath, 'utf-8');
  31. return ignore().add(content.split('\n'));
  32. }
  33. return ignore();
  34. }
  35. function shouldIgnore(relPath: string, ignorer: ignore.Ignore): boolean {
  36. if (DEFAULT_IGNORE.some((pattern) => relPath.includes(pattern))) {
  37. return true;
  38. }
  39. return ignorer.ignores(relPath);
  40. }
  41. function getFiles(
  42. folder: string,
  43. extensions: string[],
  44. ignorer: ignore.Ignore,
  45. ): string[] {
  46. const files: string[] = [];
  47. function scan(dir: string, base: string = '') {
  48. const entries = readdirSync(dir, { withFileTypes: true });
  49. for (const entry of entries) {
  50. const fullPath = join(dir, entry.name);
  51. const relPath = base ? join(base, entry.name) : entry.name;
  52. if (shouldIgnore(relPath, ignorer)) {
  53. continue;
  54. }
  55. if (entry.isDirectory()) {
  56. scan(fullPath, relPath);
  57. } else if (entry.isFile()) {
  58. const ext = entry.name.includes('.')
  59. ? '.' + entry.name.split('.').pop()!
  60. : '';
  61. if (extensions.includes(ext)) {
  62. files.push(relPath);
  63. }
  64. }
  65. }
  66. }
  67. scan(folder);
  68. return files.sort((a, b) => a.localeCompare(b));
  69. }
  70. async function calculateHashes(
  71. folder: string,
  72. files: string[],
  73. ): Promise<Map<string, string>> {
  74. const hashes = new Map<string, string>();
  75. for (const file of files) {
  76. const fullPath = join(folder, file);
  77. try {
  78. const content = await Bun.file(fullPath).text();
  79. hashes.set(file, await md5(content));
  80. } catch (error) {
  81. console.error(`Failed to hash ${file}:`, error);
  82. }
  83. }
  84. return hashes;
  85. }
  86. async function calculateFolderHash(
  87. fileHashes: Map<string, string>,
  88. ): Promise<string> {
  89. const hasher = await createMD5();
  90. hasher.init();
  91. const sortedEntries = Array.from(fileHashes.entries()).sort(([a], [b]) =>
  92. a.localeCompare(b),
  93. );
  94. for (const [path, hash] of sortedEntries) {
  95. hasher.update(`${path}:${hash}|`);
  96. }
  97. return hasher.digest();
  98. }
  99. interface ParsedFrontmatter {
  100. frontmatter: Frontmatter | null;
  101. body: string;
  102. }
  103. function parseFrontmatter(content: string): ParsedFrontmatter {
  104. const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  105. if (!match) {
  106. return { frontmatter: null, body: content };
  107. }
  108. try {
  109. const frontmatter = parse(match[1]) as Frontmatter;
  110. return { frontmatter, body: match[2] };
  111. } catch {
  112. return { frontmatter: null, body: content };
  113. }
  114. }
  115. function formatFrontmatter(frontmatter: Frontmatter): string {
  116. return `---
  117. h: ${frontmatter.h}
  118. f:
  119. ${frontmatter.f.map((f) => ` - p: ${f.p}\n h: ${f.h}`).join('\n')}
  120. ---
  121. `;
  122. }
  123. async function updateCodemap(
  124. folder: string,
  125. extensions: string[],
  126. ): Promise<{ updated: boolean; fileCount: number; changedFiles: string[] }> {
  127. const ignorer = parseGitignore(folder);
  128. const files = getFiles(folder, extensions, ignorer);
  129. const fileHashes = await calculateHashes(folder, files);
  130. const folderHash = await calculateFolderHash(fileHashes);
  131. const codemapPath = join(folder, 'codemap.md');
  132. let body = '';
  133. let changedFiles: string[] = [];
  134. if (existsSync(codemapPath)) {
  135. const content = readFileSync(codemapPath, 'utf-8');
  136. const { frontmatter, body: existingBody } = parseFrontmatter(content);
  137. if (frontmatter?.h === folderHash) {
  138. return { updated: false, fileCount: files.length, changedFiles: [] };
  139. }
  140. body = existingBody;
  141. if (frontmatter) {
  142. const oldHashes = new Map(frontmatter.f.map((f) => [f.p, f.h]));
  143. for (const [path, hash] of fileHashes) {
  144. if (oldHashes.get(path) !== hash) {
  145. changedFiles.push(path);
  146. }
  147. }
  148. } else {
  149. changedFiles = files;
  150. }
  151. } else {
  152. changedFiles = files;
  153. }
  154. const frontmatter: Frontmatter = {
  155. h: folderHash,
  156. f: files.map((p) => ({ p, h: fileHashes.get(p)! })),
  157. };
  158. const content = formatFrontmatter(frontmatter) + body;
  159. writeFileSync(codemapPath, content, 'utf-8');
  160. return { updated: true, fileCount: files.length, changedFiles };
  161. }
  162. async function main() {
  163. const command = process.argv[2];
  164. const folderArg = process.argv[3];
  165. const folder = folderArg ? resolve(folderArg) : process.cwd();
  166. const extArg = process.argv.find((a) => a.startsWith('--extensions'));
  167. const extensions = extArg
  168. ? extArg
  169. .split('=')[1]
  170. .split(',')
  171. .map((e) => '.' + e.trim().replace(/^\./, ''))
  172. : ['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs'];
  173. switch (command) {
  174. case 'scan': {
  175. const ignorer = parseGitignore(folder);
  176. const files = getFiles(folder, extensions, ignorer);
  177. console.log(JSON.stringify({ folder, files }, null, 2));
  178. break;
  179. }
  180. case 'hash': {
  181. const ignorer = parseGitignore(folder);
  182. const files = getFiles(folder, extensions, ignorer);
  183. const fileHashes = await calculateHashes(folder, files);
  184. const folderHash = await calculateFolderHash(fileHashes);
  185. console.log(
  186. JSON.stringify(
  187. {
  188. folderHash,
  189. files: Object.fromEntries(fileHashes),
  190. },
  191. null,
  192. 2,
  193. ),
  194. );
  195. break;
  196. }
  197. case 'update': {
  198. const result = await updateCodemap(folder, extensions);
  199. if (result.updated) {
  200. console.log(
  201. JSON.stringify(
  202. {
  203. updated: true,
  204. folder,
  205. fileCount: result.fileCount,
  206. changedFiles: result.changedFiles,
  207. },
  208. null,
  209. 2,
  210. ),
  211. );
  212. } else {
  213. console.log(
  214. JSON.stringify(
  215. {
  216. updated: false,
  217. folder,
  218. message: 'No changes detected',
  219. },
  220. null,
  221. 2,
  222. ),
  223. );
  224. }
  225. break;
  226. }
  227. default:
  228. console.error(
  229. 'Usage: cartography <scan|hash|update> [folder] [--extensions ts,tsx,js]',
  230. );
  231. process.exit(1);
  232. }
  233. }
  234. main().catch((error) => {
  235. console.error('Error:', error);
  236. process.exit(1);
  237. });