Bladeren bron

feat: Add `codegraph affected` command to find test files impacted by changes

Traverses dependency graph to identify which test files depend on changed source files. Supports stdin input for git integration, custom test file patterns, and configurable traversal depth. Useful for targeted test execution in CI/CD pipelines.
Colby McHenry 3 maanden geleden
bovenliggende
commit
5334a0f023
5 gewijzigde bestanden met toevoegingen van 198 en 4 verwijderingen
  1. 42 0
      README.md
  2. 2 2
      package-lock.json
  3. 1 1
      package.json
  4. 140 0
      src/bin/codegraph.ts
  5. 13 1
      src/graph/queries.ts

+ 42 - 0
README.md

@@ -289,6 +289,7 @@ codegraph status [path]     # Show statistics
 codegraph query <search>    # Search symbols
 codegraph files [path]      # Show project file structure
 codegraph context <task>    # Build context for AI
+codegraph affected [files]  # Find test files affected by changes
 codegraph serve --mcp       # Start MCP server
 ```
 
@@ -400,6 +401,47 @@ codegraph context "add user authentication" --format json
 codegraph context "refactor payment service" --max-nodes 30
 ```
 
+### `codegraph affected [files...]`
+
+Find test files affected by changed source files. Traces import dependencies transitively through the graph to discover which test files depend on the code you changed. Works with any test framework and any language CodeGraph supports.
+
+```bash
+codegraph affected src/utils.ts src/api.ts         # Pass files as arguments
+git diff --name-only | codegraph affected --stdin   # Pipe from git diff
+codegraph affected --stdin --json < changed.txt     # JSON output
+codegraph affected src/auth.ts --filter "e2e/*"     # Custom test file pattern
+codegraph affected src/lib.ts --depth 3 --quiet     # Shallow search, paths only
+```
+
+**Options:**
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| `--stdin` | Read file list from stdin (one per line) | `false` |
+| `-d, --depth <n>` | Max dependency traversal depth | `5` |
+| `-f, --filter <glob>` | Custom glob to identify test files | auto-detect |
+| `-j, --json` | Output as JSON | `false` |
+| `-q, --quiet` | Output file paths only, no decoration | `false` |
+| `-p, --path <path>` | Project path | auto-detect |
+
+**How it works:**
+
+1. For each changed file, BFS-traverses its transitive dependents (files that import from it, directly or indirectly)
+2. Filters results to test files using common conventions (`*.spec.*`, `*.test.*`, `e2e/`, `tests/`, `__tests__/`) or a custom `--filter` glob
+3. Changed files that are themselves test files are always included
+
+**Example: CI/hook integration**
+
+```bash
+#!/usr/bin/env bash
+# In a pre-commit hook or CI step:
+AFFECTED=$(git diff --name-only HEAD | codegraph affected --stdin --quiet)
+if [ -n "$AFFECTED" ]; then
+  echo "Running affected tests..."
+  npx vitest run $AFFECTED
+fi
+```
+
 ### `codegraph serve`
 
 Start CodeGraph as an MCP server for AI assistants.

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "@colbymchenry/codegraph",
-  "version": "0.6.2",
+  "version": "0.6.4",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@colbymchenry/codegraph",
-      "version": "0.6.2",
+      "version": "0.6.4",
       "hasInstallScript": true,
       "license": "MIT",
       "dependencies": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@colbymchenry/codegraph",
-  "version": "0.6.2",
+  "version": "0.6.4",
   "description": "Supercharge Claude Code with semantic code intelligence. 30% fewer tokens, 25% fewer tool calls, 100% local.",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",

+ 140 - 0
src/bin/codegraph.ts

@@ -15,6 +15,7 @@
  *   codegraph query <search>     Search for symbols
  *   codegraph files [options]    Show project file structure
  *   codegraph context <task>     Build context for a task
+ *   codegraph affected [files]   Find test files affected by changes
  *   codegraph mark-dirty [path]  Mark project as needing sync (hooks)
  *   codegraph sync-if-dirty [path] Sync if marked dirty (hooks)
  *
@@ -1067,6 +1068,145 @@ program
     }
   });
 
+/**
+ * codegraph affected [files...]
+ *
+ * Find test files affected by the given source files.
+ * Traces dependency edges transitively to find test files that depend on changed code.
+ *
+ * Usage:
+ *   git diff --name-only | codegraph affected --stdin
+ *   codegraph affected src/lib/components/Editor.svelte src/routes/+page.svelte
+ */
+program
+  .command('affected [files...]')
+  .description('Find test files affected by changed source files')
+  .option('-p, --path <path>', 'Project path')
+  .option('--stdin', 'Read file list from stdin (one per line)')
+  .option('-d, --depth <number>', 'Max dependency traversal depth', '5')
+  .option('-f, --filter <glob>', 'Custom glob filter for test files (e.g. "e2e/*.spec.ts")')
+  .option('-j, --json', 'Output as JSON')
+  .option('-q, --quiet', 'Only output file paths, no decoration')
+  .action(async (fileArgs: string[], options: { path?: string; stdin?: boolean; depth?: string; filter?: string; json?: boolean; quiet?: boolean }) => {
+    const projectPath = resolveProjectPath(options.path);
+
+    try {
+      if (!isInitialized(projectPath)) {
+        error(`CodeGraph not initialized in ${projectPath}`);
+        process.exit(1);
+      }
+
+      // Collect changed files from args or stdin
+      let changedFiles: string[] = [...(fileArgs || [])];
+
+      if (options.stdin) {
+        const stdinData = fs.readFileSync(0, 'utf-8');
+        const stdinFiles = stdinData.split('\n').map(f => f.trim()).filter(Boolean);
+        changedFiles.push(...stdinFiles);
+      }
+
+      if (changedFiles.length === 0) {
+        if (!options.quiet) info('No files provided. Use file arguments or --stdin.');
+        process.exit(0);
+      }
+
+      const { default: CodeGraph } = await loadCodeGraph();
+      const cg = await CodeGraph.open(projectPath);
+      const maxDepth = parseInt(options.depth || '5', 10);
+
+      // Common test file patterns
+      const defaultTestPatterns = [
+        /\.spec\./,
+        /\.test\./,
+        /\/__tests__\//,
+        /\/tests?\//,
+        /\/e2e\//,
+        /\/spec\//,
+      ];
+
+      // Custom filter pattern
+      let customFilter: RegExp | null = null;
+      if (options.filter) {
+        // Convert glob to regex: ** → .+, * → [^/]*, . → \.
+        const regex = options.filter
+          .replace(/[+[\]{}()^$|\\]/g, '\\$&')
+          .replace(/\./g, '\\.')
+          .replace(/\*\*/g, '.+')
+          .replace(/\*/g, '[^/]*');
+        customFilter = new RegExp(regex);
+      }
+
+      function isTestFile(filePath: string): boolean {
+        if (customFilter) return customFilter.test(filePath);
+        return defaultTestPatterns.some(p => p.test(filePath));
+      }
+
+      // BFS to find all transitive dependents of changed files, filtered to test files
+      const affectedTests = new Set<string>();
+      const allDependents = new Set<string>();
+
+      for (const file of changedFiles) {
+        // If the changed file is itself a test file, include it
+        if (isTestFile(file)) {
+          affectedTests.add(file);
+          continue;
+        }
+
+        // BFS through dependents
+        const queue: Array<{ file: string; depth: number }> = [{ file, depth: 0 }];
+        const visited = new Set<string>();
+        visited.add(file);
+
+        while (queue.length > 0) {
+          const current = queue.shift()!;
+          if (current.depth >= maxDepth) continue;
+
+          const dependents = cg.getFileDependents(current.file);
+          for (const dep of dependents) {
+            if (visited.has(dep)) continue;
+            visited.add(dep);
+            allDependents.add(dep);
+
+            if (isTestFile(dep)) {
+              affectedTests.add(dep);
+            } else {
+              queue.push({ file: dep, depth: current.depth + 1 });
+            }
+          }
+        }
+      }
+
+      const sortedTests = Array.from(affectedTests).sort();
+
+      // Output
+      if (options.json) {
+        console.log(JSON.stringify({
+          changedFiles,
+          affectedTests: sortedTests,
+          totalDependentsTraversed: allDependents.size,
+        }, null, 2));
+      } else if (options.quiet) {
+        for (const t of sortedTests) console.log(t);
+      } else {
+        if (sortedTests.length === 0) {
+          info('No test files affected by the changed files.');
+        } else {
+          console.log(chalk.bold(`\nAffected test files (${sortedTests.length}):\n`));
+          for (const t of sortedTests) {
+            console.log('  ' + chalk.cyan(t));
+          }
+          console.log();
+        }
+      }
+
+      cg.destroy();
+    } catch (err) {
+      captureException(err);
+      error(`Affected analysis failed: ${err instanceof Error ? err.message : String(err)}`);
+      process.exit(1);
+    }
+  });
+
 /**
  * codegraph install
  */

+ 13 - 1
src/graph/queries.ts

@@ -148,7 +148,19 @@ export class GraphQueryManager {
     const nodes = this.queries.getNodesByFile(filePath);
     const dependents = new Set<string>();
 
-    // For each exported symbol in this file, find imports
+    // Check file-level incoming import edges (file:X imports file:Y)
+    const fileNode = nodes.find((n) => n.kind === 'file');
+    if (fileNode) {
+      const incomingFileEdges = this.queries.getIncomingEdges(fileNode.id, ['imports']);
+      for (const edge of incomingFileEdges) {
+        const sourceNode = this.queries.getNodeById(edge.source);
+        if (sourceNode && sourceNode.filePath !== filePath) {
+          dependents.add(sourceNode.filePath);
+        }
+      }
+    }
+
+    // Also check node-level imports of exported symbols
     for (const node of nodes) {
       if (node.isExported) {
         const incomingEdges = this.queries.getIncomingEdges(node.id, ['imports']);