exclude-config.test.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. /**
  2. * `codegraph.json` `exclude` — keep paths out of the index even when git-TRACKED
  3. * (#999).
  4. *
  5. * The escape hatch for a committed vendor/theme/SDK directory (a checked-in
  6. * Metronic theme under `static/`) that `.gitignore` cannot drop because git
  7. * tracks it. Two layers under test:
  8. * 1. Loader: parse/validate/cache, mirroring the `includeIgnored` loader.
  9. * 2. Behavior: `scanDirectory` drops excluded paths on BOTH the git
  10. * (`git ls-files`) and non-git (filesystem walk) enumeration paths — and
  11. * crucially for TRACKED files, which is the whole point.
  12. *
  13. * Invariant: every loader failure mode degrades to the zero-config default
  14. * (exclude nothing), never a throw.
  15. */
  16. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  17. import * as fs from 'node:fs';
  18. import * as path from 'node:path';
  19. import * as os from 'node:os';
  20. import { execFileSync } from 'node:child_process';
  21. import { loadExcludePatterns, loadExtensionOverrides, loadIncludeIgnoredPatterns, clearProjectConfigCache } from '../src/project-config';
  22. import { scanDirectory } from '../src/extraction';
  23. describe('exclude loader (codegraph.json)', () => {
  24. let dir: string;
  25. beforeEach(() => {
  26. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-exclude-'));
  27. clearProjectConfigCache();
  28. });
  29. afterEach(() => {
  30. clearProjectConfigCache();
  31. fs.rmSync(dir, { recursive: true, force: true });
  32. });
  33. const writeConfig = (obj: unknown) =>
  34. fs.writeFileSync(
  35. path.join(dir, 'codegraph.json'),
  36. typeof obj === 'string' ? obj : JSON.stringify(obj)
  37. );
  38. it('returns an empty list when there is no codegraph.json (the default)', () => {
  39. expect(loadExcludePatterns(dir)).toEqual([]);
  40. });
  41. it('loads a well-formed pattern array', () => {
  42. writeConfig({ exclude: ['static/', '**/vendor/**'] });
  43. expect(loadExcludePatterns(dir)).toEqual(['static/', '**/vendor/**']);
  44. });
  45. it('trims whitespace and drops blank / non-string entries', () => {
  46. writeConfig({ exclude: [' static/ ', '', ' ', 42, null, 'vendor/'] });
  47. expect(loadExcludePatterns(dir)).toEqual(['static/', 'vendor/']);
  48. });
  49. it('ignores a non-array exclude value without throwing', () => {
  50. writeConfig({ exclude: 'static/' });
  51. expect(loadExcludePatterns(dir)).toEqual([]);
  52. });
  53. it('ignores malformed JSON without throwing', () => {
  54. writeConfig('{ not: valid json ');
  55. expect(loadExcludePatterns(dir)).toEqual([]);
  56. });
  57. it('coexists with extensions and includeIgnored in one file (shared single parse)', () => {
  58. writeConfig({ extensions: { '.foo': 'typescript' }, includeIgnored: ['pkgs/'], exclude: ['static/'] });
  59. expect(loadExtensionOverrides(dir)).toEqual({ '.foo': 'typescript' });
  60. expect(loadIncludeIgnoredPatterns(dir)).toEqual(['pkgs/']);
  61. expect(loadExcludePatterns(dir)).toEqual(['static/']);
  62. });
  63. it('picks up a changed config (mtime-invalidated cache)', () => {
  64. writeConfig({ exclude: ['static/'] });
  65. expect(loadExcludePatterns(dir)).toEqual(['static/']);
  66. writeConfig({ exclude: ['assets/'] });
  67. const future = new Date(Date.now() + 2000);
  68. fs.utimesSync(path.join(dir, 'codegraph.json'), future, future);
  69. expect(loadExcludePatterns(dir)).toEqual(['assets/']);
  70. });
  71. it('drops the patterns again when the config file is removed', () => {
  72. writeConfig({ exclude: ['static/'] });
  73. expect(loadExcludePatterns(dir)).toEqual(['static/']);
  74. fs.rmSync(path.join(dir, 'codegraph.json'));
  75. expect(loadExcludePatterns(dir)).toEqual([]);
  76. });
  77. });
  78. describe('exclude behavior — scanDirectory drops excluded paths (#999)', () => {
  79. let dir: string;
  80. const mk = (rel: string, content = 'export const x = 1;\n') => {
  81. const p = path.join(dir, rel);
  82. fs.mkdirSync(path.dirname(p), { recursive: true });
  83. fs.writeFileSync(p, content);
  84. };
  85. const writeConfig = (obj: unknown) =>
  86. fs.writeFileSync(path.join(dir, 'codegraph.json'), JSON.stringify(obj));
  87. beforeEach(() => {
  88. dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-exclude-scan-'));
  89. clearProjectConfigCache();
  90. });
  91. afterEach(() => {
  92. clearProjectConfigCache();
  93. fs.rmSync(dir, { recursive: true, force: true });
  94. });
  95. const gitInit = () => {
  96. execFileSync('git', ['init', '-q'], { cwd: dir });
  97. execFileSync('git', ['add', '-A'], { cwd: dir });
  98. execFileSync('git', ['-c', 'user.email=a@b.c', '-c', 'user.name=t', 'commit', '-qm', 'x'], { cwd: dir });
  99. };
  100. it('keeps a TRACKED excluded dir out of the index (git path) — the core fix', () => {
  101. mk('app/main.ts');
  102. mk('static/theme/widget1.js');
  103. mk('static/theme/widget2.js');
  104. gitInit(); // static/ is now git-TRACKED — .gitignore could not drop it
  105. // Sanity: without exclude the tracked theme IS indexed.
  106. let files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
  107. expect(files).toContain('app/main.ts');
  108. expect(files.some((f) => f.startsWith('static/'))).toBe(true);
  109. // With exclude the tracked theme is gone, app code stays.
  110. writeConfig({ exclude: ['static/'] });
  111. clearProjectConfigCache();
  112. files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
  113. expect(files).toContain('app/main.ts');
  114. expect(files.some((f) => f.startsWith('static/'))).toBe(false);
  115. });
  116. it('excludes a tracked dir on the non-git filesystem-walk path too', () => {
  117. mk('app/main.ts');
  118. mk('static/theme/widget1.js');
  119. // No git init → scanDirectory falls back to the filesystem walk.
  120. writeConfig({ exclude: ['static/'] });
  121. clearProjectConfigCache();
  122. const files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
  123. expect(files).toContain('app/main.ts');
  124. expect(files.some((f) => f.startsWith('static/'))).toBe(false);
  125. });
  126. it('supports a double-star glob', () => {
  127. mk('src/a.ts');
  128. mk('packages/x/vendor/lib1.js');
  129. mk('packages/y/vendor/lib2.js');
  130. gitInit();
  131. writeConfig({ exclude: ['**/vendor/**'] });
  132. clearProjectConfigCache();
  133. const files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
  134. expect(files).toContain('src/a.ts');
  135. expect(files.some((f) => f.includes('/vendor/'))).toBe(false);
  136. });
  137. it('is a no-op with no exclude config (everything indexed)', () => {
  138. mk('app/main.ts');
  139. mk('static/theme/widget1.js');
  140. gitInit();
  141. const files = scanDirectory(dir).map((f) => f.replace(/\\/g, '/'));
  142. expect(files).toContain('app/main.ts');
  143. expect(files.some((f) => f.startsWith('static/'))).toBe(true);
  144. });
  145. });