include-config.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. /**
  2. * `codegraph.json` `include` — force first-party source INTO the index even when
  3. * `.gitignore` would drop it.
  4. *
  5. * The whitelist `includeIgnored` never was: that one only revives *embedded git
  6. * repos* inside ignored dirs (#622/#699), so pure source gitignored out of Git
  7. * (the SVN+Git dual-VCS case — committed to SVN, `.gitignore`d so it never lands
  8. * in Git) had no way in. Three layers under test:
  9. * 1. Loader: parse/validate/cache, mirroring the `exclude` loader.
  10. * 2. Behavior: `scanDirectory` adds included paths on BOTH the git
  11. * (`git ls-files`) and non-git (filesystem walk) enumeration paths.
  12. * 3. Scope: `buildScopeIgnore` (the watcher's source of truth) treats an
  13. * included file — and the gitignored dirs leading to it — as not-ignored.
  14. *
  15. * Invariants: an explicit `exclude` still wins; built-in default-ignored dirs
  16. * (`node_modules`, …) are never resurfaced; every loader failure mode degrades
  17. * to the zero-config default (force nothing in), never a throw.
  18. */
  19. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  20. import * as fs from 'node:fs';
  21. import * as path from 'node:path';
  22. import * as os from 'node:os';
  23. import { execFileSync } from 'node:child_process';
  24. import {
  25. loadIncludePatterns,
  26. loadExcludePatterns,
  27. loadExtensionOverrides,
  28. loadIncludeIgnoredPatterns,
  29. clearProjectConfigCache,
  30. } from '../src/project-config';
  31. import { scanDirectory, buildScopeIgnore } from '../src/extraction';
  32. describe('include loader (codegraph.json)', () => {
  33. let dir: string;
  34. beforeEach(() => {
  35. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-include-'));
  36. clearProjectConfigCache();
  37. });
  38. afterEach(() => {
  39. clearProjectConfigCache();
  40. fs.rmSync(dir, { recursive: true, force: true });
  41. });
  42. const writeConfig = (obj: unknown) =>
  43. fs.writeFileSync(
  44. path.join(dir, 'codegraph.json'),
  45. typeof obj === 'string' ? obj : JSON.stringify(obj)
  46. );
  47. it('returns an empty list when there is no codegraph.json (the default)', () => {
  48. expect(loadIncludePatterns(dir)).toEqual([]);
  49. });
  50. it('loads a well-formed pattern array', () => {
  51. writeConfig({ include: ['Tools/', 'Local/**'] });
  52. expect(loadIncludePatterns(dir)).toEqual(['Tools/', 'Local/**']);
  53. });
  54. it('trims whitespace and drops blank / non-string entries', () => {
  55. writeConfig({ include: [' Tools/ ', '', ' ', 42, null, 'Local/'] });
  56. expect(loadIncludePatterns(dir)).toEqual(['Tools/', 'Local/']);
  57. });
  58. it('ignores a non-array include value without throwing', () => {
  59. writeConfig({ include: 'Tools/' });
  60. expect(loadIncludePatterns(dir)).toEqual([]);
  61. });
  62. it('ignores malformed JSON without throwing', () => {
  63. writeConfig('{ not: valid json ');
  64. expect(loadIncludePatterns(dir)).toEqual([]);
  65. });
  66. it('coexists with extensions / includeIgnored / exclude in one file (shared single parse)', () => {
  67. writeConfig({
  68. extensions: { '.foo': 'typescript' },
  69. includeIgnored: ['pkgs/'],
  70. exclude: ['static/'],
  71. include: ['Tools/'],
  72. });
  73. expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'typescript' });
  74. expect(loadIncludeIgnoredPatterns(dir)).toEqual(['pkgs/']);
  75. expect(loadExcludePatterns(dir)).toEqual(['static/']);
  76. expect(loadIncludePatterns(dir)).toEqual(['Tools/']);
  77. });
  78. it('picks up a changed config (mtime-invalidated cache)', () => {
  79. writeConfig({ include: ['Tools/'] });
  80. expect(loadIncludePatterns(dir)).toEqual(['Tools/']);
  81. writeConfig({ include: ['Local/'] });
  82. const future = new Date(Date.now() + 2000);
  83. fs.utimesSync(path.join(dir, 'codegraph.json'), future, future);
  84. expect(loadIncludePatterns(dir)).toEqual(['Local/']);
  85. });
  86. it('drops the patterns again when the config file is removed', () => {
  87. writeConfig({ include: ['Tools/'] });
  88. expect(loadIncludePatterns(dir)).toEqual(['Tools/']);
  89. fs.rmSync(path.join(dir, 'codegraph.json'));
  90. expect(loadIncludePatterns(dir)).toEqual([]);
  91. });
  92. });
  93. describe('include behavior — scanDirectory force-indexes gitignored source', () => {
  94. let dir: string;
  95. const mk = (rel: string, content = 'export const x = 1;\n') => {
  96. const p = path.join(dir, rel);
  97. fs.mkdirSync(path.dirname(p), { recursive: true });
  98. fs.writeFileSync(p, content);
  99. };
  100. const writeConfig = (obj: unknown) =>
  101. fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify(obj));
  102. const scan = () => scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
  103. beforeEach(() => {
  104. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-include-scan-'));
  105. clearProjectConfigCache();
  106. });
  107. afterEach(() => {
  108. clearProjectConfigCache();
  109. fs.rmSync(dir, { recursive: true, force: true });
  110. });
  111. const gitInit = () => {
  112. execFileSync('git', ['init', '-q'], { cwd: dir });
  113. execFileSync('git', ['add', '-A'], { cwd: dir });
  114. execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=t', 'commit', '-qm', 'x'], { cwd: dir });
  115. };
  116. it('indexes a .gitignored source dir when include opts it in (git path) — the core fix', () => {
  117. mk('app/main.ts');
  118. mk('Tools/gen.py', 'def gen():\n return 1\n');
  119. fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n'); // SVN-only source, kept out of Git
  120. gitInit(); // Tools/ is gitignored → NOT tracked
  121. // Sanity: without include the gitignored source is invisible.
  122. let files = scan();
  123. expect(files).toContain('app/main.ts');
  124. expect(files.some((f) => f.startsWith('Tools/'))).toBe(false);
  125. // With include the gitignored source is forced in, app code still there.
  126. writeConfig({ include: ['Tools/'] });
  127. clearProjectConfigCache();
  128. files = scan();
  129. expect(files).toContain('app/main.ts');
  130. expect(files).toContain('Tools/gen.py');
  131. });
  132. it('forces gitignored source in on the non-git filesystem-walk path too', () => {
  133. mk('app/main.ts');
  134. mk('Tools/gen.py', 'def gen():\n return 1\n');
  135. fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n');
  136. // No git init → scanDirectory falls back to the filesystem walk (which still
  137. // honours .gitignore), so Tools/ must be re-added by include.
  138. writeConfig({ include: ['Tools/'] });
  139. clearProjectConfigCache();
  140. const files = scan();
  141. expect(files).toContain('app/main.ts');
  142. expect(files).toContain('Tools/gen.py');
  143. });
  144. it('supports a recursive ** glob and nested dirs', () => {
  145. mk('src/a.ts');
  146. mk('Local/ts/a.ts');
  147. mk('Local/ts/nested/b.ts');
  148. fs.writeFileSync(path.join(dir, '.gitignore'), 'Local/\n');
  149. gitInit();
  150. writeConfig({ include: ['Local/**'] });
  151. clearProjectConfigCache();
  152. const files = scan();
  153. expect(files).toContain('Local/ts/a.ts');
  154. expect(files).toContain('Local/ts/nested/b.ts');
  155. });
  156. it('lets an explicit exclude win over include', () => {
  157. mk('Tools/keep.py', 'def k():\n return 1\n');
  158. mk('Tools/secret/drop.py', 'def d():\n return 1\n');
  159. fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n');
  160. gitInit();
  161. writeConfig({ include: ['Tools/'], exclude: ['Tools/secret/'] });
  162. clearProjectConfigCache();
  163. const files = scan();
  164. expect(files).toContain('Tools/keep.py');
  165. expect(files.some((f) => f.startsWith('Tools/secret/'))).toBe(false);
  166. });
  167. it('prunes an explicitly-excluded subtree under an included dir (a frontend own deps stay out)', () => {
  168. // The real-world case: an SVN-committed frontend is force-included, but its
  169. // own vendored deps live in a NON-default-named dir (`third_party/`) the
  170. // built-in ignore list does not cover, so it is excluded explicitly. The
  171. // whole subtree - nested files and all - must stay out, while sibling source
  172. // stays in.
  173. mk('Local/frontend/src/app.ts');
  174. mk('Local/frontend/src/util.ts');
  175. mk('Local/frontend/third_party/lib/a.ts');
  176. mk('Local/frontend/third_party/lib/nested/b.ts');
  177. fs.writeFileSync(path.join(dir, '.gitignore'), 'Local/\n');
  178. gitInit();
  179. writeConfig({ include: ['Local/frontend/'], exclude: ['Local/frontend/third_party/'] });
  180. clearProjectConfigCache();
  181. const files = scan();
  182. expect(files).toContain('Local/frontend/src/app.ts');
  183. expect(files).toContain('Local/frontend/src/util.ts');
  184. expect(files.some((f) => f.startsWith('Local/frontend/third_party/'))).toBe(false);
  185. });
  186. it('never resurrects a built-in default-ignored dir (node_modules) via include', () => {
  187. mk('src/a.ts');
  188. mk('node_modules/pkg/index.js');
  189. gitInit();
  190. // Even explicitly opting node_modules in must not pull it into the graph.
  191. writeConfig({ include: ['node_modules/'] });
  192. clearProjectConfigCache();
  193. const files = scan();
  194. expect(files).toContain('src/a.ts');
  195. expect(files.some((f) => f.startsWith('node_modules/'))).toBe(false);
  196. });
  197. it('is a no-op with no include config (gitignored source stays out)', () => {
  198. mk('app/main.ts');
  199. mk('Tools/gen.py', 'def gen():\n return 1\n');
  200. fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\n');
  201. gitInit();
  202. const files = scan();
  203. expect(files).toContain('app/main.ts');
  204. expect(files.some((f) => f.startsWith('Tools/'))).toBe(false);
  205. });
  206. });
  207. describe('include scope — buildScopeIgnore keeps included paths watchable', () => {
  208. let dir: string;
  209. beforeEach(() => {
  210. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-include-scope-'));
  211. clearProjectConfigCache();
  212. execFileSync('git', ['init', '-q'], { cwd: dir });
  213. fs.writeFileSync(path.join(dir, '.gitignore'), 'Tools/\nOther/\n');
  214. fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify({ include: ['Tools/'] }));
  215. });
  216. afterEach(() => {
  217. clearProjectConfigCache();
  218. fs.rmSync(dir, { recursive: true, force: true });
  219. });
  220. it('does not ignore an included file, nor the gitignored dir leading to it', () => {
  221. const scope = buildScopeIgnore(dir);
  222. // The included file and its (gitignored) directory are watchable.
  223. expect(scope.ignores('Tools/gen.py')).toBe(false);
  224. expect(scope.ignores('Tools/')).toBe(false);
  225. // A different gitignored dir that was NOT opted in stays ignored.
  226. expect(scope.ignores('Other/')).toBe(true);
  227. expect(scope.ignores('Other/x.py')).toBe(true);
  228. });
  229. it('still ignores everything when no include is configured', () => {
  230. fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify({}));
  231. clearProjectConfigCache();
  232. const scope = buildScopeIgnore(dir);
  233. expect(scope.ignores('Tools/gen.py')).toBe(true);
  234. expect(scope.ignores('Tools/')).toBe(true);
  235. });
  236. });