project-config.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. /**
  2. * Project-scoped configuration: a committed `codegraph.json` at the project
  3. * root that a team shares through version control.
  4. *
  5. * Today it carries one thing — `extensions`, an opt-in map from a custom file
  6. * extension to one of CodeGraph's supported languages. The built-in
  7. * extension → language table (`EXTENSION_MAP` in `extraction/grammars.ts`) is
  8. * otherwise hardcoded, so a codebase that uses a non-standard extension for a
  9. * supported language (e.g. `.dota_lua` for Lua) sees those files silently
  10. * skipped. This lets the project map them once, in a version-controlled file:
  11. *
  12. * {
  13. * "extensions": {
  14. * ".dota_lua": "lua",
  15. * ".tpl": "php"
  16. * }
  17. * }
  18. *
  19. * User mappings merge on TOP of the built-ins and win on conflict, so a project
  20. * can also re-point a built-in extension (e.g. force `.h` → `cpp`). Absent or
  21. * malformed config is the zero-config default — no overrides, no error. Invalid
  22. * individual entries are warned-and-skipped (never fatal): an unparseable
  23. * project file must not break indexing.
  24. */
  25. import * as fs from 'fs';
  26. import * as path from 'path';
  27. import { Language } from './types';
  28. import { isLanguageSupported } from './extraction/grammars';
  29. import { logWarn } from './errors';
  30. /** Filename of the project-scoped config, resolved relative to the project root. */
  31. export const PROJECT_CONFIG_FILENAME = 'codegraph.json';
  32. export interface ProjectConfig {
  33. /** Map of custom file extension (`.foo`) to a supported language id. */
  34. extensions?: Record<string, string>;
  35. /**
  36. * Gitignore-style patterns naming gitignored directories whose embedded git
  37. * repositories should be indexed anyway — the explicit opt-in to override
  38. * `.gitignore` for nested-repo discovery (#622, #699). Absent/empty (the
  39. * default) means `.gitignore` is fully respected: gitignored embedded repos
  40. * are never discovered or indexed (#970, #976).
  41. */
  42. includeIgnored?: string[];
  43. /**
  44. * Gitignore-style patterns for paths to keep OUT of the index — even when
  45. * they are git-TRACKED, which `.gitignore` cannot do (#999). The escape hatch
  46. * for a committed vendor/theme/SDK directory (e.g. a checked-in Metronic theme
  47. * under `static/`) that bloats the graph and slows indexing but isn't really
  48. * your code. Matched against project-root-relative paths, so a directory like
  49. * `"static/"`, a double-star vendor glob, or `"assets/theme"` all work.
  50. * Absent/empty (the default) excludes nothing beyond the built-in defaults
  51. * and your `.gitignore`.
  52. */
  53. exclude?: string[];
  54. /**
  55. * Gitignore-style patterns for first-party source to force INTO the index even
  56. * when `.gitignore` would drop it — the general whitelist `includeIgnored`
  57. * never was (that one only revives *embedded git repos* inside ignored dirs).
  58. * The case this exists for: a project under a second VCS (SVN, Perforce, …)
  59. * deliberately `.gitignore`s its own real source so it never lands in Git, yet
  60. * that source must still be indexed. Matched against project-root-relative
  61. * paths, so `"Tools/"`, a recursive `"Tools/**"` glob, or `"Local/typescript"`
  62. * all work.
  63. * Built-in default-ignored dirs (`node_modules`, `dist`, …), `.git`, and
  64. * CodeGraph's own data dir are never resurfaced; an explicit `exclude` still
  65. * wins. Absent/empty (the default) forces nothing in.
  66. */
  67. include?: string[];
  68. }
  69. /** Parsed, validated view of a project's `codegraph.json`. */
  70. interface ParsedConfig {
  71. extensions: Record<string, Language>;
  72. includeIgnored: string[];
  73. exclude: string[];
  74. include: string[];
  75. }
  76. interface CacheEntry {
  77. mtimeMs: number;
  78. config: ParsedConfig;
  79. }
  80. /**
  81. * Cache keyed by project root. The loader is called once per indexing/scan/sync
  82. * operation (and per watch event), so the mtime guard keeps repeat calls to one
  83. * `stat` while a single `codegraph.json` is in force. Keying by root keeps two
  84. * projects in the same process (the daemon / multi-project MCP server) isolated.
  85. */
  86. const cache = new Map<string, CacheEntry>();
  87. /** Shared frozen empties so the no-config path allocates nothing. */
  88. const EMPTY_EXTENSIONS: Record<string, Language> = Object.freeze({});
  89. const EMPTY_CONFIG: ParsedConfig = Object.freeze({
  90. extensions: EMPTY_EXTENSIONS,
  91. includeIgnored: Object.freeze([]) as unknown as string[],
  92. exclude: Object.freeze([]) as unknown as string[],
  93. include: Object.freeze([]) as unknown as string[],
  94. });
  95. /**
  96. * Normalize a user-provided extension key to the `.ext` lowercase form used by
  97. * the built-in map. Returns null for keys that can never match a real file
  98. * extension (so the caller warns and skips):
  99. * - empty / just "."
  100. * - multi-part (".d.ts") — language detection keys off the FINAL extension
  101. * only (`lastIndexOf('.')`), so a multi-dot key would never be consulted.
  102. * - anything containing a path separator.
  103. */
  104. function normalizeExtKey(raw: string): string | null {
  105. if (typeof raw !== 'string') return null;
  106. let ext = raw.trim().toLowerCase();
  107. if (!ext) return null;
  108. if (!ext.startsWith('.')) ext = '.' + ext;
  109. const body = ext.slice(1);
  110. if (!body) return null;
  111. if (body.includes('.') || body.includes('/') || body.includes('\\')) return null;
  112. return ext;
  113. }
  114. /**
  115. * Read + JSON-parse a `codegraph.json` once and return its validated view.
  116. * Every failure mode degrades to the zero-config default — a missing file, bad
  117. * JSON, or a typo'd value never throws.
  118. */
  119. function parseConfig(file: string): ParsedConfig {
  120. let raw: string;
  121. try {
  122. raw = fs.readFileSync(file, 'utf-8');
  123. } catch {
  124. return EMPTY_CONFIG;
  125. }
  126. let parsed: unknown;
  127. try {
  128. parsed = JSON.parse(raw);
  129. } catch (err) {
  130. logWarn(`Ignoring ${PROJECT_CONFIG_FILENAME}: not valid JSON`, {
  131. file,
  132. error: err instanceof Error ? err.message : String(err),
  133. });
  134. return EMPTY_CONFIG;
  135. }
  136. if (!parsed || typeof parsed !== 'object') return EMPTY_CONFIG;
  137. const extensions = extractExtensions(parsed, file);
  138. const includeIgnored = extractIncludeIgnored(parsed, file);
  139. const exclude = extractExclude(parsed, file);
  140. const include = extractInclude(parsed, file);
  141. if (
  142. extensions === EMPTY_EXTENSIONS &&
  143. includeIgnored.length === 0 &&
  144. exclude.length === 0 &&
  145. include.length === 0
  146. ) {
  147. return EMPTY_CONFIG;
  148. }
  149. return { extensions, includeIgnored, exclude, include };
  150. }
  151. /**
  152. * Validate the `extensions` map. Every failure mode degrades to "no overrides
  153. * from this entry" — a bad value or a typo'd language never throws.
  154. */
  155. function extractExtensions(parsed: object, file: string): Record<string, Language> {
  156. const exts = (parsed as ProjectConfig).extensions;
  157. if (!exts || typeof exts !== 'object' || Array.isArray(exts)) return EMPTY_EXTENSIONS;
  158. const out: Record<string, Language> = {};
  159. for (const [rawKey, rawVal] of Object.entries(exts)) {
  160. const key = normalizeExtKey(rawKey);
  161. if (!key) {
  162. logWarn(`Ignoring extension mapping in ${PROJECT_CONFIG_FILENAME}: "${rawKey}" is not a valid file extension`, { file });
  163. continue;
  164. }
  165. if (typeof rawVal !== 'string' || !isLanguageSupported(rawVal as Language)) {
  166. logWarn(`Ignoring extension "${rawKey}" in ${PROJECT_CONFIG_FILENAME}: "${String(rawVal)}" is not a supported language`, { file });
  167. continue;
  168. }
  169. out[key] = rawVal as Language;
  170. }
  171. return Object.keys(out).length > 0 ? out : EMPTY_EXTENSIONS;
  172. }
  173. /**
  174. * Validate the `includeIgnored` patterns: an array of non-empty gitignore-style
  175. * strings. A non-array value or a non-string/blank entry warns-and-skips; never
  176. * throws. Patterns are kept verbatim (trimmed) so they match exactly as a
  177. * `.gitignore` line would.
  178. */
  179. function extractIncludeIgnored(parsed: object, file: string): string[] {
  180. const raw = (parsed as ProjectConfig).includeIgnored;
  181. if (raw === undefined) return [];
  182. if (!Array.isArray(raw)) {
  183. logWarn(`Ignoring "includeIgnored" in ${PROJECT_CONFIG_FILENAME}: must be an array of gitignore-style patterns`, { file });
  184. return [];
  185. }
  186. const out: string[] = [];
  187. for (const entry of raw) {
  188. if (typeof entry !== 'string' || !entry.trim()) {
  189. logWarn(`Ignoring an "includeIgnored" entry in ${PROJECT_CONFIG_FILENAME}: every pattern must be a non-empty string`, { file });
  190. continue;
  191. }
  192. out.push(entry.trim());
  193. }
  194. return out;
  195. }
  196. /**
  197. * Validate the `exclude` patterns: an array of non-empty gitignore-style
  198. * strings naming paths to keep out of the index even when git-tracked (#999). A
  199. * non-array value or a non-string/blank entry warns-and-skips; never throws.
  200. * Patterns are kept verbatim (trimmed) so they match exactly as a `.gitignore`
  201. * line would, against project-root-relative paths.
  202. */
  203. function extractExclude(parsed: object, file: string): string[] {
  204. const raw = (parsed as ProjectConfig).exclude;
  205. if (raw === undefined) return [];
  206. if (!Array.isArray(raw)) {
  207. logWarn(`Ignoring "exclude" in ${PROJECT_CONFIG_FILENAME}: must be an array of gitignore-style patterns`, { file });
  208. return [];
  209. }
  210. const out: string[] = [];
  211. for (const entry of raw) {
  212. if (typeof entry !== 'string' || !entry.trim()) {
  213. logWarn(`Ignoring an "exclude" entry in ${PROJECT_CONFIG_FILENAME}: every pattern must be a non-empty string`, { file });
  214. continue;
  215. }
  216. out.push(entry.trim());
  217. }
  218. return out;
  219. }
  220. /**
  221. * Validate the `include` patterns: an array of non-empty gitignore-style strings
  222. * naming first-party source to force INTO the index despite `.gitignore` — the
  223. * whitelist for SVN/Perforce-only source a project gitignores out of Git (the
  224. * general case `includeIgnored` never covered). A non-array value or a
  225. * non-string/blank entry warns-and-skips; never throws. Patterns are kept
  226. * verbatim (trimmed) so they match exactly as a `.gitignore` line would, against
  227. * project-root-relative paths.
  228. */
  229. function extractInclude(parsed: object, file: string): string[] {
  230. const raw = (parsed as ProjectConfig).include;
  231. if (raw === undefined) return [];
  232. if (!Array.isArray(raw)) {
  233. logWarn(`Ignoring "include" in ${PROJECT_CONFIG_FILENAME}: must be an array of gitignore-style patterns`, { file });
  234. return [];
  235. }
  236. const out: string[] = [];
  237. for (const entry of raw) {
  238. if (typeof entry !== 'string' || !entry.trim()) {
  239. logWarn(`Ignoring an "include" entry in ${PROJECT_CONFIG_FILENAME}: every pattern must be a non-empty string`, { file });
  240. continue;
  241. }
  242. out.push(entry.trim());
  243. }
  244. return out;
  245. }
  246. /**
  247. * Load the parsed `codegraph.json` for a project, mtime-cached. A missing or
  248. * malformed file yields the zero-config default. One `stat` (and at most one
  249. * read/parse) while a single config file is in force, shared across every field.
  250. */
  251. function loadParsedConfig(rootDir: string): ParsedConfig {
  252. const file = path.join(rootDir, PROJECT_CONFIG_FILENAME);
  253. let mtimeMs: number;
  254. try {
  255. mtimeMs = fs.statSync(file).mtimeMs;
  256. } catch {
  257. // No config file — drop any stale cache entry and return the default.
  258. cache.delete(rootDir);
  259. return EMPTY_CONFIG;
  260. }
  261. const entry = cache.get(rootDir);
  262. if (entry && entry.mtimeMs === mtimeMs) return entry.config;
  263. const config = parseConfig(file);
  264. cache.set(rootDir, { mtimeMs, config });
  265. return config;
  266. }
  267. /**
  268. * Load the validated extension overrides for a project, mtime-cached.
  269. *
  270. * Returns a map of `.ext` → supported language id. The result merges on top of
  271. * the built-in extension map at the point of use (see `detectLanguage` /
  272. * `isSourceFile`), with these user mappings taking precedence. Returns an empty
  273. * map when there is no `codegraph.json` (the zero-config default).
  274. */
  275. export function loadExtensionOverrides(rootDir: string): Record<string, Language> {
  276. return loadParsedConfig(rootDir).extensions;
  277. }
  278. /**
  279. * Load the validated `includeIgnored` patterns for a project, mtime-cached.
  280. *
  281. * These name gitignored directories whose embedded git repositories should be
  282. * indexed despite `.gitignore` (#622, #699). An empty result — the zero-config
  283. * default — means `.gitignore` is fully respected: gitignored embedded repos
  284. * are never discovered or indexed (#970, #976).
  285. */
  286. export function loadIncludeIgnoredPatterns(rootDir: string): string[] {
  287. return loadParsedConfig(rootDir).includeIgnored;
  288. }
  289. /**
  290. * Load the validated `exclude` patterns for a project, mtime-cached.
  291. *
  292. * These name paths to keep OUT of the index even when git-tracked — the escape
  293. * hatch for a committed vendor/theme/SDK directory `.gitignore` can't drop
  294. * (#999). An empty result — the zero-config default — excludes nothing beyond
  295. * the built-in defaults and the project's `.gitignore`.
  296. */
  297. export function loadExcludePatterns(rootDir: string): string[] {
  298. return loadParsedConfig(rootDir).exclude;
  299. }
  300. /**
  301. * Load the validated `include` patterns for a project, mtime-cached.
  302. *
  303. * These name first-party source to force INTO the index even when `.gitignore`
  304. * would drop it — the whitelist for SVN/Perforce-only source a project
  305. * gitignores out of Git. An empty result — the zero-config default — forces
  306. * nothing in. Built-in default-ignored dirs, `.git`, and CodeGraph's data dir
  307. * are never resurfaced, and an explicit `exclude` still wins.
  308. */
  309. export function loadIncludePatterns(rootDir: string): string[] {
  310. return loadParsedConfig(rootDir).include;
  311. }
  312. /** Test/maintenance hook: forget cached config (e.g. after rewriting it in a test). */
  313. export function clearProjectConfigCache(): void {
  314. cache.clear();
  315. }
  316. /**
  317. * Add gitignore-style patterns to a project's `codegraph.json` `includeIgnored`
  318. * list, creating the file if absent and preserving every other key. Used by the
  319. * CLI to opt a "super-repo of gitignored child repos" (#1156) into the index on
  320. * the user's say-so. Returns the count of patterns actually ADDED (ones already
  321. * present are skipped, so a re-run is idempotent).
  322. *
  323. * A plain-JSON round-trip: a `codegraph.json` carrying comments (not valid JSON)
  324. * already fails to load with a warning, so rather than silently clobber such a
  325. * file this throws when an existing config won't parse — the caller falls back
  326. * to printing the manual snippet. Invalidates the config cache so a subsequent
  327. * index in the same process sees the new patterns.
  328. */
  329. export function addIncludeIgnoredPatterns(rootDir: string, patterns: string[]): number {
  330. const file = path.join(rootDir, PROJECT_CONFIG_FILENAME);
  331. let config: Record<string, unknown> = {};
  332. let raw: string | null = null;
  333. try {
  334. raw = fs.readFileSync(file, 'utf-8');
  335. } catch {
  336. raw = null; // missing file — create a fresh one below
  337. }
  338. if (raw !== null) {
  339. let parsed: unknown;
  340. try {
  341. parsed = JSON.parse(raw);
  342. } catch {
  343. throw new Error(`${PROJECT_CONFIG_FILENAME} is not valid JSON — fix it by hand, then re-run.`);
  344. }
  345. if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
  346. config = parsed as Record<string, unknown>;
  347. }
  348. }
  349. const existing = Array.isArray(config.includeIgnored)
  350. ? (config.includeIgnored as unknown[]).filter((p): p is string => typeof p === 'string')
  351. : [];
  352. const merged = [...existing];
  353. const seen = new Set(existing);
  354. let added = 0;
  355. for (const p of patterns) {
  356. if (seen.has(p)) continue;
  357. seen.add(p);
  358. merged.push(p);
  359. added++;
  360. }
  361. config.includeIgnored = merged;
  362. fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
  363. clearProjectConfigCache();
  364. return added;
  365. }