@diffci.com/diffci 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +301 -226
  3. package/action.yml +154 -154
  4. package/dist-client/src/client/cli.js +153 -21
  5. package/dist-client/src/client/economics.js +31 -0
  6. package/dist-client/src/client/mcp.js +196 -0
  7. package/dist-client/src/client/observe.js +40 -9
  8. package/docs/agent-adoption-kit.md +95 -0
  9. package/docs/agent-adoption-targets.md +52 -0
  10. package/docs/ai-agents.md +87 -0
  11. package/docs/claude-code.md +17 -0
  12. package/docs/codex.md +17 -0
  13. package/docs/copilot.md +18 -0
  14. package/docs/cursor.md +18 -0
  15. package/docs/grok.md +17 -0
  16. package/docs/language-support.md +118 -70
  17. package/docs/mcp.md +21 -0
  18. package/llms.txt +37 -0
  19. package/node_modules/@babel/parser/bin/babel-parser.js +0 -0
  20. package/node_modules/@diffci.com/core/README.md +9 -1
  21. package/node_modules/@diffci.com/core/dist/cache/economics-context.d.ts +2 -0
  22. package/node_modules/@diffci.com/core/dist/cache/economics-context.js +21 -0
  23. package/node_modules/@diffci.com/core/dist/cache/economics-context.js.map +1 -0
  24. package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.d.ts +5 -0
  25. package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.js +100 -0
  26. package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.js.map +1 -0
  27. package/node_modules/@diffci.com/core/dist/planner/test-command.js +32 -0
  28. package/node_modules/@diffci.com/core/dist/planner/test-command.js.map +1 -1
  29. package/node_modules/@diffci.com/core/dist/repo/adapters/go.d.ts +3 -0
  30. package/node_modules/@diffci.com/core/dist/repo/adapters/go.js +24 -6
  31. package/node_modules/@diffci.com/core/dist/repo/adapters/go.js.map +1 -1
  32. package/node_modules/@diffci.com/core/dist/repo/adapters/index.js +2 -1
  33. package/node_modules/@diffci.com/core/dist/repo/adapters/index.js.map +1 -1
  34. package/node_modules/@diffci.com/core/dist/repo/adapters/maven.d.ts +3 -0
  35. package/node_modules/@diffci.com/core/dist/repo/adapters/maven.js +65 -0
  36. package/node_modules/@diffci.com/core/dist/repo/adapters/maven.js.map +1 -0
  37. package/node_modules/@diffci.com/core/dist/repo/adapters/types.d.ts +11 -0
  38. package/node_modules/@diffci.com/core/dist/repo/adapters/types.js.map +1 -1
  39. package/node_modules/@diffci.com/core/dist/repo/adapters/vue.js +142 -14
  40. package/node_modules/@diffci.com/core/dist/repo/adapters/vue.js.map +1 -1
  41. package/node_modules/@diffci.com/core/dist/repo/graph.d.ts +4 -0
  42. package/node_modules/@diffci.com/core/dist/repo/graph.js +169 -19
  43. package/node_modules/@diffci.com/core/dist/repo/graph.js.map +1 -1
  44. package/node_modules/@diffci.com/core/dist/repo/impact.js +22 -5
  45. package/node_modules/@diffci.com/core/dist/repo/impact.js.map +1 -1
  46. package/node_modules/@diffci.com/core/dist/repo/repo-config.d.ts +15 -0
  47. package/node_modules/@diffci.com/core/dist/repo/repo-config.js +13 -2
  48. package/node_modules/@diffci.com/core/dist/repo/repo-config.js.map +1 -1
  49. package/node_modules/@diffci.com/core/dist/repo/test-discovery.js +2 -0
  50. package/node_modules/@diffci.com/core/dist/repo/test-discovery.js.map +1 -1
  51. package/node_modules/@diffci.com/core/dist/repo/types.d.ts +17 -0
  52. package/node_modules/@diffci.com/core/dist/repo/vue-scope.d.ts +4 -0
  53. package/node_modules/@diffci.com/core/dist/repo/vue-scope.js +157 -0
  54. package/node_modules/@diffci.com/core/dist/repo/vue-scope.js.map +1 -0
  55. package/node_modules/@diffci.com/core/src/cache/economics-context.ts +19 -0
  56. package/node_modules/@diffci.com/core/src/cache/vue-analysis-cache.ts +80 -0
  57. package/node_modules/@diffci.com/core/src/planner/test-command.ts +28 -0
  58. package/node_modules/@diffci.com/core/src/repo/adapters/go.ts +24 -5
  59. package/node_modules/@diffci.com/core/src/repo/adapters/index.ts +2 -1
  60. package/node_modules/@diffci.com/core/src/repo/adapters/maven.ts +38 -0
  61. package/node_modules/@diffci.com/core/src/repo/adapters/types.ts +5 -0
  62. package/node_modules/@diffci.com/core/src/repo/adapters/vue.ts +120 -15
  63. package/node_modules/@diffci.com/core/src/repo/graph.ts +146 -20
  64. package/node_modules/@diffci.com/core/src/repo/impact.ts +20 -4
  65. package/node_modules/@diffci.com/core/src/repo/repo-config.ts +20 -2
  66. package/node_modules/@diffci.com/core/src/repo/test-discovery.ts +2 -0
  67. package/node_modules/@diffci.com/core/src/repo/types.ts +11 -0
  68. package/node_modules/@diffci.com/core/src/repo/vue-scope.ts +122 -0
  69. package/node_modules/nanoid/bin/nanoid.cjs +0 -0
  70. package/node_modules/typescript/bin/tsc +0 -0
  71. package/node_modules/typescript/bin/tsserver +0 -0
  72. package/node_modules/yaml/bin.mjs +0 -0
  73. package/package.json +155 -131
@@ -1,10 +1,11 @@
1
- import { existsSync, readdirSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { createTestFileMatcher, DEFAULT_TEST_FILE_MATCHER, testFileMatcherForProfile, type TestFileMatcher } from "./test-discovery.js";
3
3
  import { isBuiltin } from "node:module";
4
4
  import { dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
5
5
  import ts from "typescript";
6
6
  import { adapterFiles, REPOSITORY_ADAPTERS } from "./adapters/index.js";
7
7
  import { analyzeRepository, type AnalyzeRepositoryOptions } from "./analyzer.js";
8
+ import { applyVueScope, inVuePackage } from "./vue-scope.js";
8
9
  import type {
9
10
  DependencyEdge,
10
11
  DependencyEdgeKind,
@@ -382,6 +383,7 @@ function createProgram(
382
383
  repoPath: string,
383
384
  fallbackSourceRoots: SourceRoot[] = [],
384
385
  additionalSources: string[] = [],
386
+ syntaxOnly = false,
385
387
  ): {
386
388
  program: ts.Program;
387
389
  options: ts.CompilerOptions;
@@ -521,7 +523,10 @@ function createProgram(
521
523
  }
522
524
  const program = ts.createProgram({
523
525
  rootNames: fileNames,
524
- options,
526
+ // Scoped analysis already inventories every implementation file. It extracts
527
+ // syntax/import edges, never asks TypeScript for semantic diagnostics. Keep
528
+ // the original options below for explicit module resolution.
529
+ options: syntaxOnly ? { ...options, noResolve: true, noLib: true, types: [] } : options,
525
530
  configFileParsingDiagnostics,
526
531
  });
527
532
 
@@ -600,45 +605,82 @@ function findAssetCandidate(
600
605
  return undefined;
601
606
  }
602
607
 
603
- export interface BuildDependencyGraphOptions extends AnalyzeRepositoryOptions {}
608
+ export interface BuildDependencyGraphOptions extends AnalyzeRepositoryOptions {
609
+ vueAnalysisCache?: { directory: string; version: string };
610
+ }
604
611
 
605
612
  export async function buildDependencyGraph(
606
613
  options: BuildDependencyGraphOptions = {},
607
614
  ): Promise<DependencyGraphResult> {
608
615
  const start = process.hrtime.bigint();
616
+ const phasesMs: Record<string, number> = {};
617
+ let phaseStart = start;
618
+ const markPhase = (name: string) => {
619
+ const now = process.hrtime.bigint();
620
+ phasesMs[name] = Number(now - phaseStart) / 1_000_000;
621
+ phaseStart = now;
622
+ };
609
623
  const repoPath = options.repoPath ? resolve(options.repoPath) : process.cwd();
610
624
 
611
625
  const profile = analyzeRepository(options);
612
- const files = adapterFiles(repoPath, options.excludeDirs);
613
- const context = { repoPath, files, profile };
614
- const contributions = REPOSITORY_ADAPTERS.filter((adapter) => adapter.detect(context)).map((adapter) => adapter.analyze(context));
615
- const adapterBlockers = contributions.flatMap((item) => item.blockers);
626
+ markPhase("repositoryDiscovery");
627
+ const scopeBlockers = applyVueScope(repoPath, profile);
628
+ markPhase("scopeDiscovery");
629
+ const scope = profile.vueScope;
630
+ const physicalRepoRoot = scope ? realpathSync.native(repoPath) : "";
631
+ const physicalPackageRoot = scope ? realpathSync.native(join(repoPath, scope.packageRoot)) : "";
632
+ const scopePathChecks = new Map<string, boolean>();
633
+ const outsideScope = (path: string): boolean => {
634
+ if (!scope) return false;
635
+ const cached = scopePathChecks.get(path);
636
+ if (cached !== undefined) return cached;
637
+ const lexicalOutside = !inVuePackage(path, scope.packageRoot) && !path.split("/").includes("node_modules");
638
+ let outside = lexicalOutside;
639
+ if (!outside && existsSync(join(repoPath, path))) {
640
+ const physical = realpathSync.native(join(repoPath, path));
641
+ const packageRelative = relative(physicalPackageRoot, physical).replace(/\\/g, "/");
642
+ const repositoryRelative = relative(physicalRepoRoot, physical).replace(/\\/g, "/");
643
+ outside = (packageRelative === ".." || packageRelative.startsWith("../") || /^[A-Za-z]:|^\//.test(packageRelative)) &&
644
+ !(!repositoryRelative.startsWith("../") && !/^[A-Za-z]:|^\//.test(repositoryRelative) && (repositoryRelative.startsWith("node_modules/") || repositoryRelative.includes("/node_modules/")));
645
+ }
646
+ scopePathChecks.set(path, outside);
647
+ return outside;
648
+ };
649
+ const files = adapterFiles(repoPath, options.excludeDirs).filter(path => !scope || inVuePackage(path, scope.packageRoot));
650
+ markPhase("adapterInventory");
651
+ const context = { repoPath, files, profile, vueAnalysisSession: {} };
652
+ const cachedVue = options.vueAnalysisCache ? (await import("../cache/vue-analysis-cache.js")).analyzeVueCached : undefined;
653
+ const contributions = REPOSITORY_ADAPTERS.filter((adapter) => adapter.detect(context)).map((adapter) => adapter.id === "vue" && cachedVue && options.vueAnalysisCache
654
+ ? cachedVue(context, options.vueAnalysisCache.directory, options.vueAnalysisCache.version) : adapter.analyze(context));
655
+ markPhase("adapters");
656
+ const adapterBlockers = [...scopeBlockers, ...contributions.flatMap((item) => item.blockers)];
657
+ if (profile.diffciConfig?.configurationError && !adapterBlockers.includes(profile.diffciConfig.configurationError)) adapterBlockers.push(profile.diffciConfig.configurationError);
616
658
  if (contributions.some((item) => item.id === "go") && files.some((file) => /\.(?:[cm]?[jt]sx?|vue)$/.test(file))) {
617
659
  adapterBlockers.push("Mixed Go/JavaScript repositories require explicit cross-language dependencies; full validation required");
618
660
  }
619
- if (contributions.length && files.some((file) => /\.(?:py|rs|java|kt|cs|svelte|astro)$/.test(file))) {
661
+ if (contributions.length && files.some((file) => /\.(?:py|rs|cs|svelte|astro)$/.test(file) || !contributions.some((item) => item.id === "maven") && /\.(?:java|kt)$/.test(file))) {
620
662
  adapterBlockers.push("Unmodeled languages alongside an adapter require full validation");
621
663
  }
622
664
  profile.adapters = contributions.map(({ id, version, blockers }) => ({ id, version, blockers }));
623
665
  profile.goTestPackages = Object.assign({}, ...contributions.map((item) => item.testPackages));
624
666
  profile.goTestEnvironment = contributions.find((item) => item.id === "go")?.executionEnv;
667
+ profile.mavenTestModules = Object.assign({}, ...contributions.filter((item) => item.id === "maven").map((item) => item.testPackages));
625
668
  const adapterTests = contributions.flatMap((item) => item.testFiles);
626
669
  profile.testFilePaths = [...new Set([...profile.testFilePaths, ...adapterTests])].sort();
627
670
  if (profile.testUniverse) {
628
671
  profile.testUniverse.discoveredTestFiles = profile.testFilePaths.length;
629
- profile.testUniverse.blindSpot = (profile.testUniverse.declaredFrameworks.length > 0 || contributions.some((item) => item.id === "go")) && profile.testFilePaths.length === 0;
672
+ profile.testUniverse.blindSpot = (profile.testUniverse.declaredFrameworks.length > 0 || contributions.some((item) => item.id === "go" || item.id === "maven")) && profile.testFilePaths.length === 0;
630
673
  }
631
674
  const entryPointPaths = new Set(profile.entryPoints.map((e) => e.path));
632
675
 
633
- const vueSources = contributions.some((item) => item.id === "vue")
676
+ const vueSources = scope || contributions.some((item) => item.id === "vue")
634
677
  ? files.filter((file) => /\.[cm]?[jt]sx?$/.test(file)).map((file) => join(repoPath, file)) : [];
635
- // Compiler include/exclude controls typechecking, not the runner's test universe. Parse every
636
- // discovered JS/TS test so source changes can reach tests outside the compiler's root files.
637
- // Adding those tests only as leaf nodes silently loses their dependency edges (ky, 2026-09-19).
678
+ // Runner tests excluded by tsconfig still need their import edges in the graph.
638
679
  const testSources = profile.testFilePaths
639
680
  .filter((file) => /\.[cm]?[jt]sx?$/.test(file))
640
681
  .map((file) => join(repoPath, file));
641
- let { program, options: compilerOptions, resolvedViaProjectReferences } = createProgram(repoPath, profile.sourceRoots, [...vueSources, ...testSources]);
682
+ let { program, options: compilerOptions, resolvedViaProjectReferences } = createProgram(scope ? join(repoPath, scope.packageRoot) : repoPath, scope ? [] : profile.sourceRoots, [...vueSources, ...testSources], Boolean(scope));
683
+ markPhase("typescriptProgram");
642
684
  let moduleResolutionCache = ts.createModuleResolutionCache(
643
685
  repoPath,
644
686
  (x) => x,
@@ -647,7 +689,7 @@ export async function buildDependencyGraph(
647
689
 
648
690
  const sourceFiles = program
649
691
  .getSourceFiles()
650
- .filter((sf) => sf.fileName && !sf.fileName.endsWith(".d.ts"));
692
+ .filter((sf) => sf.fileName && !sf.fileName.endsWith(".d.ts") && (!scope || inVuePackage(toRelativeInternal(repoPath, sf.fileName) ?? "", scope.packageRoot)));
651
693
  for (const item of contributions) {
652
694
  for (const virtual of item.virtualSources) {
653
695
  sourceFiles.push(ts.createSourceFile(join(repoPath, virtual.path), virtual.source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX));
@@ -710,7 +752,8 @@ export async function buildDependencyGraph(
710
752
  addEdge(importerRel, assetRel, "asset");
711
753
  }
712
754
 
713
- const filesParsed = sourceFiles.length;
755
+ let filesParsed = sourceFiles.length;
756
+ let followedImplementationFiles = 0;
714
757
 
715
758
  for (const sf of sourceFiles) {
716
759
  const importerRel = toRelativeInternal(repoPath, sf.fileName);
@@ -718,6 +761,7 @@ export async function buildDependencyGraph(
718
761
  if (isExcludedPath(importerRel, options.excludeDirs ?? [])) continue;
719
762
 
720
763
  const refs = extractImportRefs(sf);
764
+ if (scope && sf.referencedFiles.length) adapterBlockers.push(`Triple-slash file references in the scoped suite require full validation: ${importerRel}`);
721
765
  for (const ref of refs) {
722
766
  if (!ref.specifier) {
723
767
  recordUnresolved(importerRel, ref, "empty specifier");
@@ -781,8 +825,34 @@ export async function buildDependencyGraph(
781
825
 
782
826
  const resolved = resolution.resolvedModule.resolvedFileName;
783
827
  const targetRel = toRelativeInternal(repoPath, resolved);
828
+ // The generic internal-path helper intentionally hides root node_modules.
829
+ // Scope validation must still follow those paths to catch workspace symlinks.
830
+ const scopeTarget = scope ? relative(physicalRepoRoot, realpathSync.native(resolved)).replace(/\\/g, "/") : targetRel;
831
+ if (scopeTarget && outsideScope(scopeTarget)) {
832
+ adapterBlockers.push(`Vue dependency crosses the declared package boundary: ${importerRel} -> ${scopeTarget}`);
833
+ continue;
834
+ }
835
+ if (scope && resolution.resolvedModule.isExternalLibraryImport && (!targetRel || targetRel.split("/").includes("node_modules"))) {
836
+ recordReference("external-package", importerRel, ref);
837
+ continue;
838
+ }
784
839
 
785
840
  if (targetRel && isSourceFileName(resolved)) {
841
+ if (scope && !resolved.endsWith(".d.ts") && !internalSourcePaths.has(targetRel)) {
842
+ // A real import can reach generated implementation excluded from the
843
+ // initial walk. Parse it (and its imports) rather than silently dropping
844
+ // the edge or loading every TypeScript declaration library again.
845
+ try {
846
+ if (isExcludedPath(targetRel, options.excludeDirs ?? []) || followedImplementationFiles >= 500 || statSync(resolved).size > 5 * 1024 * 1024) throw new Error("excluded or exceeds parse budget");
847
+ const followed = ts.createSourceFile(resolved, readFileSync(resolved, "utf8"), ts.ScriptTarget.Latest, true);
848
+ if ((followed as unknown as { parseDiagnostics?: unknown[] }).parseDiagnostics?.length) throw new Error("invalid implementation syntax");
849
+ sourceFiles.push(followed);
850
+ followedImplementationFiles++;
851
+ } catch {
852
+ adapterBlockers.push(`Resolved implementation cannot be included in the scoped source inventory: ${targetRel}`);
853
+ continue;
854
+ }
855
+ }
786
856
  internalSourcePaths.add(targetRel);
787
857
  addEdge(importerRel, targetRel, ref.kind);
788
858
  recordReference("internal-source", importerRel, ref);
@@ -808,13 +878,65 @@ export async function buildDependencyGraph(
808
878
  }
809
879
  }
810
880
 
881
+ // Only a verified scoped suite can establish that a component is not executed.
882
+ // Every test and setup/config root must actually have been parsed, not merely
883
+ // added as a leaf test node. Unknown reachable components still block the suite.
884
+ if (scope && scopeBlockers.length === 0 && profile.testFilePaths.length) {
885
+ const roots = [...profile.testFilePaths, ...(profile.vueSetupPaths ?? [])];
886
+ const parsedPaths = new Set(sourceFiles.map(file => toRelativeInternal(repoPath, file.fileName)).filter(Boolean));
887
+ if (roots.every(path => parsedPaths.has(path))) {
888
+ const outgoing = new Map<string, string[]>();
889
+ for (const edge of edges) outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge.to]);
890
+ const reachable = new Set(roots); const pending = [...roots];
891
+ while (pending.length) for (const target of outgoing.get(pending.pop()!) ?? []) if (!reachable.has(target)) { reachable.add(target); pending.push(target); }
892
+ const vue = contributions.find(item => item.id === "vue");
893
+ const irrelevant = new Set((vue?.fileBlockers ?? []).filter(item => !reachable.has(item.path)).map(item => item.reason));
894
+ const outOfSuiteBlockers = irrelevant.size;
895
+ if (profile.vueRuntimeIsolationVerified) {
896
+ const closure = (starts: string[]) => {
897
+ const visited = new Set(starts); const queue = [...starts];
898
+ while (queue.length) for (const target of outgoing.get(queue.pop()!) ?? []) if (!visited.has(target)) { visited.add(target); queue.push(target); }
899
+ return visited;
900
+ };
901
+ const shared = closure(profile.vueSetupPaths ?? []);
902
+ const runtime = (vue?.fileBlockers ?? []).filter(item => item.reason === `Vue ${item.path}: runtime component/directive resolution requires full validation` && reachable.has(item.path));
903
+ // Unknown outgoing runtime edges affect every test reaching this component.
904
+ // Run all such isolated test files for EVERY delta, regardless of static impact.
905
+ // Shared setup/configuration uncertainty cannot be confined to those files.
906
+ if (runtime.length && runtime.every(item => !shared.has(item.path))) {
907
+ const targets = new Set(runtime.map(item => item.path));
908
+ profile.vueRuntimeAlwaysRunPaths = profile.testFilePaths.filter(path => [...closure([path])].some(dependency => targets.has(dependency)));
909
+ for (const item of runtime) irrelevant.add(item.reason);
910
+ }
911
+ }
912
+ for (let i = adapterBlockers.length - 1; i >= 0; i--) if (irrelevant.has(adapterBlockers[i])) adapterBlockers.splice(i, 1);
913
+ if (vue?.performance) Object.assign(vue.performance.counts, { verifiedSuiteRoots: roots.length, reachablePaths: reachable.size, outOfSuiteBlockers, runtimeAlwaysRunTests: profile.vueRuntimeAlwaysRunPaths?.length ?? 0 });
914
+ }
915
+ }
811
916
  if (unresolved.some((ref) => ref.importer.endsWith(".vue") || stripImportQuery(ref.specifier).endsWith(".vue"))) {
812
917
  adapterBlockers.push("Unresolved Vue dependencies require full validation");
813
918
  }
919
+ filesParsed = sourceFiles.length;
920
+ if (scope && unresolved.length) adapterBlockers.push("Unresolved dependencies in the declared Vue suite require full validation");
921
+ if (scope && edges.some(edge => outsideScope(edge.from) || outsideScope(edge.to))) adapterBlockers.push("Vue asset or macro dependency crosses the declared package boundary");
814
922
  profile.adapterBlockers = [...adapterBlockers];
815
-
816
- // Keep adapter-provided test identities visible as well. JS/TS tests are parsed above; their
817
- // imports must not be replaced by disconnected leaf nodes merely because tsconfig excludes them.
923
+ markPhase("importExtractionAndResolution");
924
+
925
+ // Nested-package test visibility (2026-08-24, biomejs/biome finding): `internalSourcePaths` above is
926
+ // strictly the TS PROGRAM's own file list (createProgram()'s `include`/nested-tsconfig-merged
927
+ // fileNames) - so a package whose own tsconfig deliberately excludes its test directory (a real,
928
+ // common pattern; confirmed verbatim on biome: `packages/@biomejs/js-api/tsconfig.json` has
929
+ // `"exclude": ["./tests", "./dist"], "include": ["./src"]`) NEVER contributes those files to the
930
+ // program, so they never became graph nodes and `totalTestsInGraph` stayed 0 even though
931
+ // `profile.testFilePaths` (the separate, tsconfig-agnostic glob walk in analyzer.ts's discoverTests())
932
+ // already found them correctly. Source-ROOT discovery itself was already correct (the 2026-08-21
933
+ // zod/trpc fallback already lists `packages`/`crates` as roots for exactly this monorepo shape) - the
934
+ // gap was narrower: the graph never incorporated what that walk found. Fix: union in any test file
935
+ // discoverTests() found that the TS program's own file list missed, as an ADDITIONAL leaf node
936
+ // (isTest true; no import edges - we have no real resolution info for a file the type-checker was
937
+ // never asked to see, so dependency-graph traversal through it is honestly absent, not guessed at).
938
+ // This does NOT add Rust visibility of any kind - testFilePaths only ever contains files already
939
+ // matched by the JS/TS test-file patterns; a `.rs` test is never in it and stays "unknown" as before.
818
940
  for (const testPath of profile.testFilePaths) {
819
941
  if (!internalSourcePaths.has(testPath) && !assetPaths.has(testPath)) internalSourcePaths.add(testPath);
820
942
  }
@@ -867,9 +989,12 @@ export async function buildDependencyGraph(
867
989
  };
868
990
 
869
991
  const durationMs = Number(process.hrtime.bigint() - start) / 1_000_000;
992
+ markPhase("graphFinalization");
870
993
 
871
994
  const performance: GraphPerformanceMetrics = {
872
995
  durationMs,
996
+ phasesMs,
997
+ adapterMetrics: Object.fromEntries(contributions.filter(item => item.performance).map(item => [item.id, item.performance!])),
873
998
  heapUsedMb: heapDuringBuildMb,
874
999
  heapAfterExtractionMb,
875
1000
  filesDiscovered: filesParsed,
@@ -902,7 +1027,8 @@ export function classifyRepositoryProject(repoPath: string): { capable: boolean;
902
1027
  const files = adapterFiles(repoPath);
903
1028
  if (files.includes("go.mod")) return { capable: true, reason: "Go module (package-level analysis)" };
904
1029
  if (files.some((file) => file.endsWith(".vue"))) return { capable: true, reason: "Vue single-file components" };
905
- return { capable: false, reason: "No TypeScript project, Vue components, or root Go module found" };
1030
+ if (files.includes("pom.xml") && files.some((file) => file.endsWith(".java") || file.endsWith(".kt"))) return { capable: true, reason: "Maven reactor (module-level Java analysis)" };
1031
+ return { capable: false, reason: "No TypeScript project, Vue components, root Go module, or Maven reactor found" };
906
1032
  }
907
1033
 
908
1034
  export interface TypeScriptProjectCapability {
@@ -1,4 +1,6 @@
1
1
  import { extname, posix } from "node:path";
2
+ import { isGoDiscoveryIgnoredPath } from "./adapters/go.js";
3
+ import { inVuePackage } from "./vue-scope.js";
2
4
  import type { ChangedFile, GitDelta } from "../git/types.js";
3
5
  import type { DependencyGraph, DependencyGraphNode, DependencyGraphResult, EntryPoint, RepositoryProfile } from "./types.js";
4
6
  import type { ChangedImpact, EntryPointImpact, ImpactEvidence, ImpactEvidencePath, ImpactReason, ImpactResult, ImpactRiskSignal, TestImpact } from "./impact-types.js";
@@ -7,7 +9,7 @@ import { repositoryLayout, UNKNOWN_REPOSITORY_LAYOUT, type RepositoryLayout } fr
7
9
  import { DEFAULT_TEST_FILE_MATCHER, matchesGlob as matchesTestGlob, testFileMatcherForProfile } from "./test-discovery.js";
8
10
  import { resolveTestFixtureOwners } from "./test-fixture-ownership.js";
9
11
 
10
- const SOURCE_EXTENSIONS = new Set([".ts",".tsx",".js",".jsx",".mjs",".cjs",".mts",".cts",".vue",".go"]);
12
+ const SOURCE_EXTENSIONS = new Set([".ts",".tsx",".js",".jsx",".mjs",".cjs",".mts",".cts",".vue",".go",".java",".kt"]);
11
13
  const ASSET_EXTENSIONS = new Set([".css",".scss",".sass",".less",".json",".jsonc",".svg",".png",".jpg",".jpeg",".gif",".webp",".ico",".bmp",".woff",".woff2",".ttf",".otf",".eot",".wasm",".md",".txt"]);
12
14
  const NEXT_ENTRY_NAMES = new Set(["page","layout","route","api","loading","error","template","not-found","middleware","generatemetadata","generatestaticparams"]);
13
15
 
@@ -242,8 +244,21 @@ export class ImpactAnalyzer {
242
244
  const evidence: ImpactEvidence[] = [];
243
245
  const riskSignals: ImpactRiskSignal[] = [];
244
246
  const fallbackReasons: string[] = [...(graphResult.adapterBlockers ?? [])];
247
+ if (profile.vueScope) {
248
+ const scope = profile.vueScope;
249
+ if (delta.files.some(file => allChangePaths(file).some(path => !inVuePackage(path, scope.packageRoot)))) fallbackReasons.push("Changes outside the declared Vue package require full validation");
250
+ const setupDependencies = new Set((profile.vueSetupPaths ?? []).flatMap(path => [path, ...graph.transitiveDependenciesOf(path)]));
251
+ if (delta.files.some(file => allChangePaths(file).some(path => setupDependencies.has(path)))) fallbackReasons.push("Vue suite configuration or shared setup dependency changed; full validation required");
252
+ }
253
+ if (profile.adapters?.some(adapter => adapter.id === "go") && delta.files.some(file => allChangePaths(file).some(isGoDiscoveryIgnoredPath))) {
254
+ fallbackReasons.push("Changes in Go discovery-excluded paths require full validation, including runtime test data");
255
+ }
256
+ const excludedGoRoots = profile.goExcludedModuleRoots ?? [];
257
+ if (delta.files.some(file => allChangePaths(file).some(path => excludedGoRoots.some(root => path.startsWith(root))))) {
258
+ fallbackReasons.push("Changes in a Go module outside the declared root-module scope require full validation");
259
+ }
245
260
  for (const file of delta.files) {
246
- if (allChangePaths(file).some((path) => /(?:^|\/)(?:go\.(?:mod|sum|work)|go\.work\.sum|(?:vite|vue|nuxt)\.config\.[^/]+)$/.test(path))) {
261
+ if (allChangePaths(file).some((path) => /(?:^|\/)(?:diffci\.json|go\.(?:mod|sum|work)|go\.work\.sum|(?:vite|vue|nuxt)\.config\.[^/]+)$/.test(path))) {
247
262
  fallbackReasons.push(`Language/framework configuration changed: ${file.path}`);
248
263
  }
249
264
  }
@@ -293,7 +308,7 @@ export class ImpactAnalyzer {
293
308
 
294
309
  this.handleStructuralNextLayout(changedImpacts, profile, affectedEntryPoints, affectedSources, evidence);
295
310
  this.handleAddedEntryPoints(delta, affectedEntryPoints, affectedSources, affectedTests, evidence, fallbackReasons);
296
- this.collectAlwaysRunTests(profile, graph, affectedTests, evidence);
311
+ this.collectAlwaysRunTests(profile, graph, affectedTests, evidence, graphResult.profile.vueRuntimeAlwaysRunPaths ?? []);
297
312
 
298
313
  // Changed-test self-selection invariant (2026-08-24): every executable directly-changed test
299
314
  // (added / modified / renamed-destination / copied-destination) MUST be present in the final
@@ -632,8 +647,9 @@ export class ImpactAnalyzer {
632
647
  graph: DependencyGraph,
633
648
  affectedTests: Map<string, TestImpact>,
634
649
  evidence: ImpactEvidence[],
650
+ graphRuntimeTests: readonly string[],
635
651
  ): void {
636
- const alwaysRunPaths = new Set<string>();
652
+ const alwaysRunPaths = new Set<string>([...(profile.vueTypeTestPaths ?? []), ...(profile.vueRuntimeAlwaysRunPaths ?? []), ...graphRuntimeTests]);
637
653
  const knownTestPaths = new Set<string>();
638
654
  for (const testLocation of profile.tests) {
639
655
  for (const node of graph.nodes) {
@@ -27,6 +27,13 @@ import { join } from "node:path";
27
27
  export interface DiffCiRepositoryConfig {
28
28
  /** Globs for tests that must be selected on every analysed change, regardless of reachability. */
29
29
  alwaysRunTests?: string[];
30
+ /** Explicitly limit Go selection to the root module's `go test ./...` universe. */
31
+ go?: { scope: "root-module" };
32
+ /** One package's default Vitest suite; paths are relative to the repository/package respectively. */
33
+ vue?: { packageRoot: string; testConfig: string };
34
+ /** Match the Maven lifecycle and profiles used by this repository's CI job. */
35
+ maven?: { goal: "test" | "verify"; profiles?: string[] };
36
+ configurationError?: string;
30
37
  }
31
38
 
32
39
  function readJsonFile(path: string): Record<string, unknown> | undefined {
@@ -49,7 +56,18 @@ function parseConfig(raw: unknown): DiffCiRepositoryConfig {
49
56
  const alwaysRunTests = Array.isArray(record.alwaysRunTests)
50
57
  ? record.alwaysRunTests.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "")
51
58
  : undefined;
52
- return alwaysRunTests && alwaysRunTests.length > 0 ? { alwaysRunTests } : {};
59
+ const go = record.go as Record<string, unknown> | undefined;
60
+ const vue = record.vue as Record<string, unknown> | undefined;
61
+ const maven = record.maven as Record<string, unknown> | undefined;
62
+ const safePath = (value: unknown): value is string => typeof value === "string" && /^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*)(?:\/[A-Za-z0-9_][A-Za-z0-9_.-]*)*$/.test(value) && !value.split("/").some(part => part === "." || part === "..");
63
+ const validVue = vue && (vue.packageRoot === "." || safePath(vue.packageRoot)) && safePath(vue.testConfig) && /^vitest\.config\.[cm]?[jt]s$/.test(vue.testConfig);
64
+ const validMaven = maven && (maven.goal === "test" || maven.goal === "verify") && (maven.profiles === undefined || Array.isArray(maven.profiles) && maven.profiles.every((profile: unknown) => typeof profile === "string" && /^[A-Za-z0-9_.-]+$/.test(profile)));
65
+ return {
66
+ ...(alwaysRunTests && alwaysRunTests.length > 0 ? { alwaysRunTests } : {}),
67
+ ...(go?.scope === "root-module" ? { go: { scope: "root-module" as const } } : {}),
68
+ ...(validVue ? { vue: { packageRoot: vue.packageRoot as string, testConfig: vue.testConfig as string } } : record.vue !== undefined ? { configurationError: "Invalid Vue package/suite scope" } : {}),
69
+ ...(validMaven ? { maven: { goal: maven.goal as "test" | "verify", ...(maven.profiles ? { profiles: maven.profiles as string[] } : {}) } } : record.maven !== undefined ? { configurationError: "Invalid Maven lifecycle goal or profiles" } : {}),
70
+ };
53
71
  }
54
72
 
55
73
  /**
@@ -60,7 +78,7 @@ export function readRepositoryConfig(repoPath: string, packageJson?: Record<stri
60
78
  const dedicated = readJsonFile(join(repoPath, "diffci.json"));
61
79
  if (dedicated) {
62
80
  const parsed = parseConfig(dedicated);
63
- if (parsed.alwaysRunTests) return parsed;
81
+ if (parsed.alwaysRunTests || parsed.go || parsed.vue || parsed.maven || parsed.configurationError) return parsed;
64
82
  }
65
83
  const pkg = packageJson ?? readJsonFile(join(repoPath, "package.json"));
66
84
  return parseConfig(pkg?.diffci);
@@ -314,6 +314,8 @@ function translateExtglobBody(body: string): string {
314
314
  }
315
315
 
316
316
  function globToRegex(pattern: string): RegExp {
317
+ // Runner globs are relative to their configured root; a leading ./ is not a directory.
318
+ pattern = pattern.replace(/^(?:\.\/)+/, "");
317
319
  // Extglob bodies contain `*`, `?` and `|` that must not be rewritten by the wildcard rules below,
318
320
  // so they are lifted out behind placeholders first and restored last.
319
321
  const groups: string[] = [];
@@ -44,6 +44,15 @@ export interface RepositoryProfile {
44
44
  adapterBlockers?: string[];
45
45
  goTestPackages?: Record<string, string>;
46
46
  goTestEnvironment?: Record<string, string>;
47
+ /** Maven/JUnit test file to owning reactor module (repo-relative, "." for root). */
48
+ mavenTestModules?: Record<string, string>;
49
+ /** Nested modules excluded by an explicit root-module execution scope. Changes here force full CI. */
50
+ goExcludedModuleRoots?: string[];
51
+ vueScope?: { packageRoot: string; testConfig: string };
52
+ vueSetupPaths?: string[];
53
+ vueTypeTestPaths?: string[];
54
+ vueRuntimeIsolationVerified?: boolean;
55
+ vueRuntimeAlwaysRunPaths?: string[];
47
56
  packageManager: PackageManager;
48
57
  packageJson: {
49
58
  name?: string;
@@ -205,6 +214,8 @@ export interface GraphIntegrityReport {
205
214
 
206
215
  export interface GraphPerformanceMetrics {
207
216
  durationMs: number;
217
+ phasesMs?: Record<string, number>;
218
+ adapterMetrics?: Record<string, { phasesMs: Record<string, number>; counts: Record<string, number> }>;
208
219
  heapUsedMb?: number;
209
220
  heapAfterExtractionMb?: number;
210
221
  filesDiscovered: number;
@@ -0,0 +1,122 @@
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import ts from "typescript";
4
+ import { analyzeRepository } from "./analyzer.js";
5
+ import type { RepositoryProfile } from "./types.js";
6
+ import { adapterFiles } from "./adapters/index.js";
7
+
8
+ export function inVuePackage(path: string, root: string): boolean {
9
+ return root === "." || path === root || path.startsWith(`${root}/`);
10
+ }
11
+
12
+ /** Scope is an explicit CI declaration, never inferred from a convenient source directory. */
13
+ export function applyVueScope(repoPath: string, profile: RepositoryProfile): string[] {
14
+ const scope = profile.diffciConfig?.vue;
15
+ if (!scope) return profile.diffciConfig?.configurationError ? [profile.diffciConfig.configurationError] : [];
16
+ const packagePath = join(repoPath, scope.packageRoot);
17
+ const prefix = (path: string) => scope.packageRoot === "." ? path.replace(/^\.\//, "") : `${scope.packageRoot}/${path.replace(/^\.\//, "")}`;
18
+ if (!existsSync(join(packagePath, "package.json")) || !existsSync(join(packagePath, scope.testConfig))) return ["Vue scope requires an existing package.json and default Vitest config"];
19
+ const actual = relative(realpathSync(repoPath), realpathSync(packagePath));
20
+ if (actual === ".." || actual.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(actual)) return ["Vue package scope escapes the repository"];
21
+ const physicallyLocal = (path: string) => {
22
+ const physical = relative(realpathSync(packagePath), realpathSync(join(packagePath, path))).replace(/\\/g, "/");
23
+ return physical !== ".." && !physical.startsWith("../") && !isAbsolute(physical);
24
+ };
25
+ if (!["package.json", scope.testConfig].every(physicallyLocal)) return ["Vue package manifest or config crosses the physical package boundary"];
26
+ const scoped = analyzeRepository({ repoPath: packagePath });
27
+ const configs = scoped.testRunnerConfigs ?? [];
28
+ if (configs.length !== 1 || configs[0].file !== scope.testConfig || configs[0].runner !== "vitest") return ["Vue scope requires exactly one verified default Vitest suite"];
29
+ if (configs[0].declaresTests && !configs[0].authoritative) return ["Vue scoped test patterns could not be fully verified"];
30
+ if ([...configs[0].includes, ...configs[0].roots].some(path => /^(?:\/|\\|[A-Za-z]:)/.test(path) || path.split(/[\\/]/).includes(".."))) return ["Vue test discovery crosses the package boundary"];
31
+ const config = ts.createSourceFile(scope.testConfig, readFileSync(join(packagePath, scope.testConfig), "utf8"), ts.ScriptTarget.Latest, true);
32
+ const blockers: string[] = [];
33
+ const setup: string[] = [prefix(scope.testConfig)];
34
+ let typecheckEnabled = false;
35
+ let runtimeIsolation = true;
36
+ const allowedImports = new Set(["vitest/config", "@vitejs/plugin-vue", "node:path", "node:url", "path", "url"]);
37
+ const factories = new Set<string>();
38
+ const vuePlugins = new Set<string>();
39
+ for (const statement of config.statements) {
40
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
41
+ if (statement.moduleSpecifier.text === "@vitejs/plugin-vue" && statement.importClause?.name) vuePlugins.add(statement.importClause.name.text);
42
+ if (statement.moduleSpecifier.text === "vitest/config" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings)) {
43
+ for (const element of statement.importClause.namedBindings.elements) if ((element.propertyName ?? element.name).text === "defineConfig") factories.add(element.name.text);
44
+ }
45
+ }
46
+ const exported = config.statements.find(ts.isExportAssignment);
47
+ let definition = exported?.expression;
48
+ if (definition && ts.isCallExpression(definition) && ts.isIdentifier(definition.expression) && factories.has(definition.expression.text) && definition.arguments.length === 1) definition = definition.arguments[0];
49
+ if (!definition || !ts.isObjectLiteralExpression(definition)) blockers.push("Vue scope requires a literal default Vitest configuration");
50
+ function visit(node: ts.Node): void {
51
+ if (ts.isSpreadAssignment(node) || ts.isComputedPropertyName(node)) blockers.push("Vue scoped config contains unmodeled configuration composition");
52
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && !allowedImports.has(node.moduleSpecifier.text)) blockers.push("Vue scoped Vitest config has an unsupported plugin or config helper");
53
+ if (ts.isPropertyAssignment(node) && (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name))) {
54
+ const name = node.name.text;
55
+ if (name === "isolate" && node.initializer.kind !== ts.SyntaxKind.TrueKeyword) runtimeIsolation = false;
56
+ if (["runner", "browser", "poolOptions", "poolMatchGlobs", "environmentMatchGlobs"].includes(name)) runtimeIsolation = false;
57
+ if (name === "pool" && (!ts.isStringLiteral(node.initializer) || !["threads", "forks"].includes(node.initializer.text))) runtimeIsolation = false;
58
+ if (name === "environment" && (!ts.isStringLiteral(node.initializer) || !["node", "jsdom", "happy-dom"].includes(node.initializer.text))) runtimeIsolation = false;
59
+ if (name === "typecheck") {
60
+ if (!ts.isObjectLiteralExpression(node.initializer)) blockers.push("Vue typecheck configuration must be literal");
61
+ else for (const property of node.initializer.properties) {
62
+ if (!ts.isPropertyAssignment(property) || !(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))) { blockers.push("Vue typecheck configuration is not fully modeled"); continue; }
63
+ if (property.name.text === "enabled") {
64
+ if (property.initializer.kind === ts.SyntaxKind.TrueKeyword) typecheckEnabled = true;
65
+ else if (property.initializer.kind !== ts.SyntaxKind.FalseKeyword) blockers.push("Vue typecheck enabled flag must be literal");
66
+ }
67
+ if (["include", "exclude", "only"].includes(property.name.text)) blockers.push("Custom Vue type-test discovery requires full validation");
68
+ }
69
+ }
70
+ if (name === "plugins" && (!ts.isArrayLiteralExpression(node.initializer) || node.initializer.elements.some(element => !ts.isCallExpression(element) || !ts.isIdentifier(element.expression) || !vuePlugins.has(element.expression.text) || element.arguments.length !== 0))) blockers.push("Vue scope supports only the default Vue compiler plugin");
71
+ if (["root", "projects", "workspace", "extends"].includes(name)) blockers.push("Vue scoped Vitest config overrides its package boundary");
72
+ if (["setupFiles", "globalSetup"].includes(name)) {
73
+ const values = ts.isArrayLiteralExpression(node.initializer) ? [...node.initializer.elements] : [node.initializer];
74
+ for (const value of values) {
75
+ if (!ts.isStringLiteral(value)) { blockers.push("Vue scoped setup paths must be literal package-local files"); continue; }
76
+ const path = relative(packagePath, resolve(packagePath, value.text)).replace(/\\/g, "/");
77
+ if (path.startsWith("../") || isAbsolute(path) || !existsSync(join(packagePath, path)) || !physicallyLocal(path)) blockers.push("Vue scoped setup path escapes or is missing from the package");
78
+ else setup.push(prefix(path));
79
+ }
80
+ }
81
+ }
82
+ ts.forEachChild(node, visit);
83
+ }
84
+ visit(config);
85
+ // Only a literal test object can establish the default isolated Vitest contract.
86
+ const testDefinitions = definition && ts.isObjectLiteralExpression(definition) ? definition.properties.filter(p => p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === "test") : [];
87
+ const testDefinition = testDefinitions.length === 1 ? testDefinitions[0] : undefined;
88
+ const literalProperties = (node: ts.Node): boolean => {
89
+ if (ts.isObjectLiteralExpression(node) && node.properties.some(p => !ts.isPropertyAssignment(p) || ts.isComputedPropertyName(p.name))) return false;
90
+ let valid = true;
91
+ ts.forEachChild(node, child => { if (!literalProperties(child)) valid = false; });
92
+ return valid;
93
+ };
94
+ profile.vueRuntimeIsolationVerified = runtimeIsolation && blockers.length === 0 && !!testDefinition && ts.isPropertyAssignment(testDefinition) && ts.isObjectLiteralExpression(testDefinition.initializer) && literalProperties(testDefinition.initializer);
95
+ // Package discovery supplies the runner universe. Do not let repository-wide docs,
96
+ // playground configs or other frameworks expand or replace this declared suite.
97
+ profile.vueScope = scope;
98
+ profile.vueSetupPaths = setup;
99
+ profile.packageJson = scoped.packageJson;
100
+ profile.sourceRoots = scoped.sourceRoots.map(root => ({ ...root, path: prefix(root.path) }));
101
+ profile.testFilePaths = scoped.testFilePaths.map(prefix);
102
+ profile.testPatterns = scoped.testPatterns?.map(prefix);
103
+ profile.testExcludePatterns = scoped.testExcludePatterns?.map(prefix);
104
+ profile.testAuthoritativePatterns = scoped.testAuthoritativePatterns?.map(prefix);
105
+ if (typecheckEnabled) {
106
+ // Vitest's typecheck.include default is independent from its runtime test include.
107
+ // Keep every type suite, rather than applying runtime reachability to type checking.
108
+ profile.vueTypeTestPaths = adapterFiles(packagePath).filter(path => /\.(?:test|spec)-d\.[cm]?[jt]sx?$/.test(path)).map(prefix);
109
+ profile.testFilePaths = [...new Set([...profile.testFilePaths, ...profile.vueTypeTestPaths])].sort();
110
+ profile.testPatterns = [...(profile.testPatterns ?? []), ...profile.vueTypeTestPaths];
111
+ profile.testAuthoritativePatterns = [...(profile.testAuthoritativePatterns ?? []), ...profile.vueTypeTestPaths];
112
+ if (!profile.vueTypeTestPaths.length) blockers.push("Enabled Vue type checking has no discovered type suites");
113
+ }
114
+ profile.testIgnoreRegexSources = scoped.testIgnoreRegexSources;
115
+ profile.testRoots = scope.packageRoot === "." ? undefined : [scope.packageRoot];
116
+ profile.tests = scoped.tests.map(test => ({ ...test, glob: prefix(test.glob) }));
117
+ profile.testRunnerConfigs = configs.map(config => ({ ...config, file: prefix(config.file), includes: config.includes.map(prefix), excludeGlobs: config.excludeGlobs.map(prefix), roots: config.roots.map(prefix) }));
118
+ profile.testUniverse = scoped.testUniverse;
119
+ profile.entryPoints = scoped.entryPoints.map(entry => ({ ...entry, path: prefix(entry.path) }));
120
+ profile.pathAliases = scoped.pathAliases.map(alias => ({ ...alias, substitutions: alias.substitutions.map(prefix) }));
121
+ return [...blockers, ...(profile.testFilePaths.length ? [] : ["Declared Vue suite has no discovered tests"])];
122
+ }
File without changes
File without changes
File without changes
File without changes