@diffci.com/diffci 0.1.11 → 0.2.0

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 (76) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +295 -229
  3. package/action.yml +154 -154
  4. package/dist-client/src/client/cli.js +213 -95
  5. package/dist-client/src/client/economics.js +31 -0
  6. package/dist-client/src/client/full-command.js +46 -0
  7. package/dist-client/src/client/mcp.js +196 -0
  8. package/dist-client/src/client/observe.js +40 -9
  9. package/dist-client/src/client/verify-savings.js +8 -2
  10. package/docs/agent-adoption-kit.md +96 -0
  11. package/docs/agent-adoption-targets.md +52 -0
  12. package/docs/ai-agents.md +27 -3
  13. package/docs/claude-code.md +3 -2
  14. package/docs/codex.md +3 -2
  15. package/docs/copilot.md +4 -4
  16. package/docs/cursor.md +4 -4
  17. package/docs/grok.md +3 -2
  18. package/docs/language-support.md +118 -70
  19. package/docs/mcp.md +21 -0
  20. package/docs/npm-adoption.md +6 -0
  21. package/llms.txt +3 -0
  22. package/node_modules/@babel/parser/bin/babel-parser.js +0 -0
  23. package/node_modules/@diffci.com/core/README.md +9 -1
  24. package/node_modules/@diffci.com/core/dist/cache/economics-context.d.ts +2 -0
  25. package/node_modules/@diffci.com/core/dist/cache/economics-context.js +21 -0
  26. package/node_modules/@diffci.com/core/dist/cache/economics-context.js.map +1 -0
  27. package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.d.ts +5 -0
  28. package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.js +100 -0
  29. package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.js.map +1 -0
  30. package/node_modules/@diffci.com/core/dist/planner/test-command.js +32 -0
  31. package/node_modules/@diffci.com/core/dist/planner/test-command.js.map +1 -1
  32. package/node_modules/@diffci.com/core/dist/repo/adapters/go.d.ts +3 -0
  33. package/node_modules/@diffci.com/core/dist/repo/adapters/go.js +24 -6
  34. package/node_modules/@diffci.com/core/dist/repo/adapters/go.js.map +1 -1
  35. package/node_modules/@diffci.com/core/dist/repo/adapters/index.js +2 -1
  36. package/node_modules/@diffci.com/core/dist/repo/adapters/index.js.map +1 -1
  37. package/node_modules/@diffci.com/core/dist/repo/adapters/maven.d.ts +3 -0
  38. package/node_modules/@diffci.com/core/dist/repo/adapters/maven.js +65 -0
  39. package/node_modules/@diffci.com/core/dist/repo/adapters/maven.js.map +1 -0
  40. package/node_modules/@diffci.com/core/dist/repo/adapters/types.d.ts +11 -0
  41. package/node_modules/@diffci.com/core/dist/repo/adapters/types.js.map +1 -1
  42. package/node_modules/@diffci.com/core/dist/repo/adapters/vue.js +142 -14
  43. package/node_modules/@diffci.com/core/dist/repo/adapters/vue.js.map +1 -1
  44. package/node_modules/@diffci.com/core/dist/repo/graph.d.ts +4 -0
  45. package/node_modules/@diffci.com/core/dist/repo/graph.js +169 -19
  46. package/node_modules/@diffci.com/core/dist/repo/graph.js.map +1 -1
  47. package/node_modules/@diffci.com/core/dist/repo/impact.js +22 -5
  48. package/node_modules/@diffci.com/core/dist/repo/impact.js.map +1 -1
  49. package/node_modules/@diffci.com/core/dist/repo/repo-config.d.ts +15 -0
  50. package/node_modules/@diffci.com/core/dist/repo/repo-config.js +13 -2
  51. package/node_modules/@diffci.com/core/dist/repo/repo-config.js.map +1 -1
  52. package/node_modules/@diffci.com/core/dist/repo/test-discovery.js +2 -0
  53. package/node_modules/@diffci.com/core/dist/repo/test-discovery.js.map +1 -1
  54. package/node_modules/@diffci.com/core/dist/repo/types.d.ts +17 -0
  55. package/node_modules/@diffci.com/core/dist/repo/vue-scope.d.ts +4 -0
  56. package/node_modules/@diffci.com/core/dist/repo/vue-scope.js +157 -0
  57. package/node_modules/@diffci.com/core/dist/repo/vue-scope.js.map +1 -0
  58. package/node_modules/@diffci.com/core/src/cache/economics-context.ts +19 -0
  59. package/node_modules/@diffci.com/core/src/cache/vue-analysis-cache.ts +80 -0
  60. package/node_modules/@diffci.com/core/src/planner/test-command.ts +28 -0
  61. package/node_modules/@diffci.com/core/src/repo/adapters/go.ts +24 -5
  62. package/node_modules/@diffci.com/core/src/repo/adapters/index.ts +2 -1
  63. package/node_modules/@diffci.com/core/src/repo/adapters/maven.ts +38 -0
  64. package/node_modules/@diffci.com/core/src/repo/adapters/types.ts +5 -0
  65. package/node_modules/@diffci.com/core/src/repo/adapters/vue.ts +120 -15
  66. package/node_modules/@diffci.com/core/src/repo/graph.ts +146 -20
  67. package/node_modules/@diffci.com/core/src/repo/impact.ts +20 -4
  68. package/node_modules/@diffci.com/core/src/repo/repo-config.ts +20 -2
  69. package/node_modules/@diffci.com/core/src/repo/test-discovery.ts +2 -0
  70. package/node_modules/@diffci.com/core/src/repo/types.ts +11 -0
  71. package/node_modules/@diffci.com/core/src/repo/vue-scope.ts +122 -0
  72. package/node_modules/nanoid/bin/nanoid.cjs +0 -0
  73. package/node_modules/typescript/bin/tsc +0 -0
  74. package/node_modules/typescript/bin/tsserver +0 -0
  75. package/node_modules/yaml/bin.mjs +0 -0
  76. package/package.json +155 -138
@@ -133,6 +133,17 @@ export function planSelectiveTestCommands(
133
133
  ): SelectiveTestCommandPlan {
134
134
  const blockers = profile.adapterBlockers ?? profile.adapters?.flatMap((adapter) => adapter.blockers) ?? [];
135
135
  if (blockers.length) return { commands: [], groups: [], unroutedPaths: [...selectedPaths], refusalReason: blockers.join("; ") };
136
+ if (profile.vueScope) {
137
+ const scope = profile.vueScope;
138
+ const known = new Set(profile.testFilePaths);
139
+ const unclaimed = selectedPaths.filter(path => !known.has(path));
140
+ if (unclaimed.length || profile.packageManager !== "pnpm") return { commands: [], groups: [], unroutedPaths: [...unclaimed], refusalReason: "Scoped Vitest execution requires pnpm and verified package test paths" };
141
+ if (!selectedPaths.length) return { commands: [], groups: [], unroutedPaths: [] };
142
+ const paths = [...selectedPaths].sort();
143
+ const local = paths.map(path => scope.packageRoot === "." ? path : path.slice(scope.packageRoot.length + 1));
144
+ const commandSpec: CommandSpec = { executable: "pnpm", args: ["--dir", scope.packageRoot, "exec", "vitest", "run", "--config", scope.testConfig, ...local] };
145
+ return { commands: [commandSpec], groups: [{ runnerId: `vitest:${scope.packageRoot}/${scope.testConfig}`, label: "Declared Vue package suite", paths, commandSpec }], unroutedPaths: [] };
146
+ }
136
147
  const goPaths = selectedPaths.filter((path) => path.endsWith(".go"));
137
148
  if (goPaths.length) {
138
149
  const packages = profile.goTestPackages ?? {};
@@ -151,6 +162,23 @@ export function planSelectiveTestCommands(
151
162
  const group: SelectiveTestCommandGroup = { runnerId: "go:test", label: "Go package tests", paths: [...goPaths].sort(), commandSpec };
152
163
  return { commands: [...jsPlan.commands, commandSpec], groups: [...jsPlan.groups, group], unroutedPaths: [] };
153
164
  }
165
+ const javaPaths = selectedPaths.filter((path) => path.endsWith(".java") || path.endsWith(".kt"));
166
+ if (javaPaths.length) {
167
+ const modules = profile.mavenTestModules ?? {};
168
+ const unclaimed = javaPaths.filter((path) => !Object.hasOwn(modules, path));
169
+ if (unclaimed.length) return { commands: [], groups: [], unroutedPaths: unclaimed, refusalReason: "Maven test files require verified reactor module metadata" };
170
+ if (selectedPaths.some((path) => !path.endsWith(".java") && !path.endsWith(".kt"))) return { commands: [], groups: [], unroutedPaths: [...selectedPaths], refusalReason: "Mixed Maven/JavaScript selective execution requires separate CI jobs" };
171
+ const targets = [...new Set(javaPaths.map((path) => modules[path]!))].sort();
172
+ if (targets.some((target) => target !== "." && (target.startsWith("/") || target.split("/").includes("..") || /[\\\r\n]/.test(target)))) {
173
+ return { commands: [], groups: [], unroutedPaths: javaPaths, refusalReason: "Invalid Maven reactor module target" };
174
+ }
175
+ const maven = profile.diffciConfig?.maven;
176
+ const goal = maven?.goal ?? "test";
177
+ const args = [...(targets.length === 1 && targets[0] === "." ? [] : ["-pl", targets.join(","), "-am"]), goal, ...(maven?.profiles?.length ? ["-P", maven.profiles.join(",")] : [])];
178
+ const commandSpec: CommandSpec = { executable: "mvn", args };
179
+ const group: SelectiveTestCommandGroup = { runnerId: "maven:surefire", label: "Maven reactor tests", paths: [...javaPaths].sort(), commandSpec };
180
+ return { commands: [commandSpec], groups: [group], unroutedPaths: [] };
181
+ }
154
182
  const paths = [...selectedPaths].sort();
155
183
  if (paths.length === 0) return { commands: [], groups: [], unroutedPaths: [] };
156
184
 
@@ -14,6 +14,7 @@ interface GoPackage {
14
14
  DepsErrors?: unknown[];
15
15
  Incomplete?: boolean;
16
16
  GoFiles?: string[];
17
+ IgnoredGoFiles?: string[];
17
18
  CgoFiles?: string[];
18
19
  TestGoFiles?: string[];
19
20
  XTestGoFiles?: string[];
@@ -59,6 +60,11 @@ function internalPath(root: string, path: string): string | undefined {
59
60
  return rel === ".." || rel.startsWith("../") || isAbsolute(rel) ? undefined : rel;
60
61
  }
61
62
 
63
+ /** go ./... excludes these names; edits still require full CI (for example testdata reads). */
64
+ export function isGoDiscoveryIgnoredPath(path: string): boolean {
65
+ return path.split("/").some(part => part === "testdata" || part.startsWith("_") || part.startsWith("."));
66
+ }
67
+
62
68
  export function analyzeGoMetadata(context: AdapterContext, output: string): AdapterContribution {
63
69
  const result = contribution(goAdapter);
64
70
  const all = parseGoList(output);
@@ -68,7 +74,10 @@ export function analyzeGoMetadata(context: AdapterContext, output: string): Adap
68
74
  const anchors = new Map<string, string>();
69
75
  const members = new Map<string, string[]>();
70
76
  for (const pkg of packages) {
71
- const files = [...(pkg.GoFiles ?? []), ...(pkg.CgoFiles ?? []), ...(pkg.TestGoFiles ?? []), ...(pkg.XTestGoFiles ?? [])];
77
+ // Associate inactive files with their package conservatively. They are not runnable
78
+ // tests, but edits (including build constraints) must still select that package and
79
+ // its dependents. Unaccounted files remain a global blocker below.
80
+ const files = [...(pkg.GoFiles ?? []), ...(pkg.CgoFiles ?? []), ...(pkg.TestGoFiles ?? []), ...(pkg.XTestGoFiles ?? []), ...(pkg.IgnoredGoFiles ?? [])];
72
81
  const paths = files.map((file) => internalPath(context.repoPath, resolve(pkg.Dir, file)));
73
82
  if (paths.some((p) => p === undefined)) { result.blockers.push("Go package contains files outside the repository"); continue; }
74
83
  const sources = paths as string[];
@@ -110,16 +119,19 @@ export function analyzeGoMetadata(context: AdapterContext, output: string): Adap
110
119
  if (!result.sourcePaths.length) result.blockers.push("Go analysis found no local packages");
111
120
  // Ignored files/build tags, generation and testdata can change the runnable universe.
112
121
  const modeled = new Set([...result.sourcePaths, ...result.assetPaths]);
113
- if (context.files.some((file) => file.endsWith(".go") && !modeled.has(file))) result.blockers.push("Go files outside the active build context require full validation");
122
+ const unmodeled = context.files.filter(file => file.endsWith(".go") && !modeled.has(file) && !isGoDiscoveryIgnoredPath(file));
123
+ if (unmodeled.length) result.blockers.push(`Go files outside the active build context require full validation: ${unmodeled.slice(0, 20).join(", ")}`);
114
124
  return result;
115
125
  }
116
126
 
117
127
  export const goAdapter: RepositoryAdapter = {
118
- id: "go", version: "1", kind: "language",
128
+ id: "go", version: "3", kind: "language",
119
129
  detect: ({ files }) => files.some((file) => file === "go.mod" || file.endsWith(".go")),
120
130
  analyze(context) {
121
131
  const failure = contribution(this);
122
- if (!context.files.includes("go.mod") || context.files.some((f) => f === "go.work" || f.endsWith("/go.mod"))) {
132
+ const nestedRoots = context.files.filter(f => f.endsWith("/go.mod")).map(f => f.slice(0, -"go.mod".length));
133
+ const scoped = context.profile.diffciConfig?.go?.scope === "root-module";
134
+ if (!context.files.includes("go.mod") || context.files.includes("go.work") || (nestedRoots.length && !scoped)) {
123
135
  failure.blockers.push("Go support requires one root go.mod; workspaces and nested modules require full validation");
124
136
  return failure;
125
137
  }
@@ -140,7 +152,14 @@ export const goAdapter: RepositoryAdapter = {
140
152
  env,
141
153
  stdio: ["ignore", "pipe", "pipe"],
142
154
  });
143
- const result = analyzeGoMetadata(context, output);
155
+ const excluded = (file: string) => nestedRoots.some(root => file.startsWith(root));
156
+ const scopedContext = scoped ? { ...context, files: context.files.filter(file => !excluded(file)) } : context;
157
+ const result = analyzeGoMetadata(scopedContext, output);
158
+ if (scoped) {
159
+ context.profile.goExcludedModuleRoots = nestedRoots;
160
+ // A local replacement can make an excluded module part of the root module's build.
161
+ if (parseGoList(output).some(pkg => pkg.Module?.Replace?.Dir)) result.blockers.push("Go root-module scope with local replacements requires full validation");
162
+ }
144
163
  result.executionEnv = { GOOS: buildEnv.GOOS, GOARCH: buildEnv.GOARCH, CGO_ENABLED: buildEnv.CGO_ENABLED, GOFLAGS: "" };
145
164
  return result;
146
165
  } catch {
@@ -1,10 +1,11 @@
1
1
  import { readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { goAdapter } from "./go.js";
4
+ import { mavenAdapter } from "./maven.js";
4
5
  import { vueAdapter } from "./vue.js";
5
6
  import type { RepositoryAdapter } from "./types.js";
6
7
 
7
- export const REPOSITORY_ADAPTERS: readonly RepositoryAdapter[] = [vueAdapter, goAdapter];
8
+ export const REPOSITORY_ADAPTERS: readonly RepositoryAdapter[] = [vueAdapter, goAdapter, mavenAdapter];
8
9
 
9
10
  /** Never follows symlinks or scans dependency/build output directories. */
10
11
  export function adapterFiles(root: string, exclusions: readonly string[] = []): string[] {
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, posix } from "node:path";
3
+ import { contribution, type AdapterContext, type AdapterContribution, type RepositoryAdapter } from "./types.js";
4
+
5
+ interface Pom { path:string; dir:string; groupId?:string; artifactId?:string; modules:string[]; dependencies:Array<{groupId:string;artifactId:string}>; }
6
+
7
+ function tags(xml:string, name:string): string[] {
8
+ const re=new RegExp(`<${name}\\b[^>]*>([\\s\\S]*?)<\\/${name}>`,"g"); const out:string[]=[]; let m:RegExpExecArray|null;
9
+ while((m=re.exec(xml))) out.push((m[1]??"").trim()); return out;
10
+ }
11
+ function first(xml:string,name:string):string|undefined { return tags(xml,name)[0]; }
12
+ function parsePom(path:string, xml:string):Pom {
13
+ const dir=posix.dirname(path)==="."?"":posix.dirname(path);
14
+ const parent=first(xml,"parent")??"";
15
+ const own=xml.replace(/<parent\b[^>]*>[\s\S]*?<\/parent>/,"");
16
+ const deps=tags(xml,"dependency").map(x=>({groupId:first(x,"groupId")??"",artifactId:first(x,"artifactId")??""})).filter(x=>x.artifactId);
17
+ return {path,dir,groupId:first(own,"groupId")??first(parent,"groupId"),artifactId:first(own,"artifactId"),modules:tags(first(xml,"modules")??"","module"),dependencies:deps};
18
+ }
19
+ function under(dir:string,path:string){return !dir||path===dir||path.startsWith(dir+"/");}
20
+ function owningPom(poms:Pom[], file:string):Pom|undefined { return poms.filter(p=>under(p.dir,file)).sort((a,b)=>b.dir.length-a.dir.length)[0]; }
21
+ function javaFiles(context:AdapterContext, poms:Pom[], pom:Pom){return context.files.filter(f=>owningPom(poms,f)===pom&&/\/src\/(?:main|test)\/(?:java|kotlin)\/.*\.(?:java|kt)$/.test("/"+f));}
22
+ function isTest(path:string){return /\/src\/test\/(?:java|kotlin)\//.test("/"+path)&&/(?:Test|Tests|TestCase|IT)\.(?:java|kt)$/.test(path);}
23
+
24
+ export function analyzeMaven(context:AdapterContext):AdapterContribution {
25
+ const r=contribution(mavenAdapter);
26
+ const poms=context.files.filter(f=>f==="pom.xml"||f.endsWith("/pom.xml")).map(path=>parsePom(path,readFileSync(context.repoPath+"/"+path,"utf8")));
27
+ if(!poms.length){r.blockers.push("Maven analysis found no pom.xml");return r;}
28
+ const byGA=new Map(poms.filter(p=>p.artifactId).map(p=>[`${p.groupId??""}:${p.artifactId}`,p]));
29
+ const byArtifact=new Map(poms.filter(p=>p.artifactId).map(p=>[p.artifactId!,p]));
30
+ const members=new Map<Pom,string[]>(), anchors=new Map<Pom,string>();
31
+ for(const p of poms){const files=javaFiles(context,poms,p); if(!files.length) continue; members.set(p,files); anchors.set(p,files[0]!); r.sourcePaths.push(...files); for(const f of files) if(isTest(f)){r.testFiles.push(f);r.testPackages[f]=p.dir||".";}}
32
+ for(const [p,files] of members){const a=anchors.get(p)!; for(const f of files) if(f!==a) r.edges.push({from:a,to:f,kind:"import"},{from:f,to:a,kind:"import"});
33
+ for(const d of p.dependencies){const normalizedGroup=d.groupId.replace(/\$\{project\.groupId\}/g,p.groupId??"").replace(/\$\{pom\.groupId\}/g,p.groupId??""); const target=byGA.get(`${normalizedGroup}:${d.artifactId}`)??byArtifact.get(d.artifactId); const ta=target&&anchors.get(target); if(ta&&ta!==a) r.edges.push({from:a,to:ta,kind:"import"});}
34
+ }
35
+ if(!r.sourcePaths.length) r.blockers.push("Maven reactor contains no Java sources");
36
+ return r;
37
+ }
38
+ export const mavenAdapter:RepositoryAdapter={id:"maven",version:"1",kind:"language",detect:({files})=>files.includes("pom.xml")&&files.some(f=>f.endsWith(".java")||f.endsWith(".kt")),analyze:analyzeMaven};
@@ -5,6 +5,9 @@ export interface AdapterContext {
5
5
  repoPath: string;
6
6
  files: readonly string[];
7
7
  profile: RepositoryProfile;
8
+ vueComponentPaths?: readonly string[];
9
+ recordVueRead?: (path: string) => void;
10
+ vueAnalysisSession?: object;
8
11
  }
9
12
 
10
13
  export interface AdapterContribution {
@@ -20,6 +23,8 @@ export interface AdapterContribution {
20
23
  executionEnv?: Record<string, string>;
21
24
  /** Global blockers: missing edges cannot be dismissed using graph reachability. */
22
25
  blockers: string[];
26
+ fileBlockers?: Array<{ path: string; reason: string }>;
27
+ performance?: { phasesMs: Record<string, number>; counts: Record<string, number> };
23
28
  }
24
29
 
25
30
  export interface RepositoryAdapter {
@@ -1,23 +1,101 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { compileScript, compileTemplate, parse } from "@vue/compiler-sfc";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import ts from "typescript";
5
+ const loadCompiler = createRequire(import.meta.url);
6
+ let previousSession: object | undefined;
7
+ const compilerConfigs = new Set<string>();
8
+
9
+ /** Only direct imports registered in a literal component options object are provable. */
10
+ function registeredComponents(source: string): Set<string> {
11
+ const file = ts.createSourceFile("component.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
12
+ const imports = new Set<string>();
13
+ const factories = new Set<string>();
14
+ for (const statement of file.statements) {
15
+ if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue;
16
+ const clause = statement.importClause;
17
+ if (clause?.name) imports.add(clause.name.text);
18
+ if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
19
+ for (const binding of clause.namedBindings.elements) {
20
+ if (binding.isTypeOnly) continue;
21
+ imports.add(binding.name.text);
22
+ if (ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "vue" && (binding.propertyName ?? binding.name).text === "defineComponent") factories.add(binding.name.text);
23
+ }
24
+ }
25
+ }
26
+ const names = new Set<string>();
27
+ const exp = file.statements.find(ts.isExportAssignment);
28
+ if (!exp || exp.isExportEquals) return names;
29
+ let value = exp.expression;
30
+ if (ts.isCallExpression(value) && ts.isIdentifier(value.expression) && factories.has(value.expression.text) && value.arguments.length === 1) value = value.arguments[0];
31
+ if (!ts.isObjectLiteralExpression(value) || value.properties.some(p => ts.isSpreadAssignment(p) || (p.name && ts.isComputedPropertyName(p.name)))) return names;
32
+ const registrations = value.properties.filter(p => p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === "components");
33
+ if (registrations.length !== 1 || !ts.isPropertyAssignment(registrations[0]) || !ts.isObjectLiteralExpression(registrations[0].initializer)) return names;
34
+ for (const property of registrations[0].initializer.properties) {
35
+ if (ts.isShorthandPropertyAssignment(property) && imports.has(property.name.text)) names.add(property.name.text);
36
+ else if (ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && ts.isIdentifier(property.initializer) && imports.has(property.initializer.text)) names.add(property.name.text);
37
+ else return new Set();
38
+ }
39
+ return new Set([...names].flatMap(name => [name, name.replace(/\B([A-Z])/g, "-$1").toLowerCase()]));
40
+ }
4
41
  import { contribution, type RepositoryAdapter } from "./types.js";
5
42
 
6
43
  /** Explicit Vue SFC imports. Runtime component registries and preprocessors require full CI. */
7
44
  export const vueAdapter: RepositoryAdapter = {
8
- id: "vue", version: "1", kind: "framework",
45
+ id: "vue", version: "7", kind: "framework",
9
46
  detect: ({ files }) => files.some((file) => file.endsWith(".vue")),
10
47
  analyze(context) {
48
+ const phasesMs: Record<string, number> = {};
49
+ const counts: Record<string, number> = { components: 0, typeReads: 0, registrationScans: 0 };
50
+ const measure = <T>(name: string, work: () => T): T => {
51
+ const start = performance.now();
52
+ try { return work(); } finally { phasesMs[name] = (phasesMs[name] ?? 0) + performance.now() - start; }
53
+ };
54
+ // Go and scoped TS-only packages do not need to initialize Vue's compiler/Babel
55
+ // dependency tree. Keep the same synchronous adapter contract, loading it on use.
56
+ const { compileScript, compileTemplate, invalidateTypeCache, parse, registerTS } = measure("compilerLoad", () => loadCompiler("@vue/compiler-sfc")) as typeof import("@vue/compiler-sfc");
57
+ registerTS(() => ts);
58
+ const session = context.vueAnalysisSession ?? {};
59
+ if (session !== previousSession) {
60
+ for (const file of compilerConfigs) invalidateTypeCache(file);
61
+ compilerConfigs.clear();
62
+ previousSession = session;
63
+ }
64
+ const recordRead = (file: string) => {
65
+ if (file.endsWith(".json")) compilerConfigs.add(file);
66
+ context.recordVueRead?.(file);
67
+ };
11
68
  const result = contribution(this);
69
+ result.performance = { phasesMs, counts };
12
70
  const dependencies = [...context.profile.packageJson.dependencies, ...context.profile.packageJson.devDependencies];
13
71
  if (dependencies.includes("nuxt") || context.files.some((file) => /(?:^|\/)nuxt\.config\./.test(file))) {
14
72
  result.blockers.push("Nuxt implicit routes and auto-imports require a dedicated framework adapter");
15
73
  }
16
- for (const path of context.files.filter((file) => file.endsWith(".vue"))) {
74
+ for (const path of context.vueComponentPaths ?? context.files.filter((file) => file.endsWith(".vue"))) {
75
+ counts.components++;
17
76
  result.sourcePaths.push(path);
18
- const block = (reason: string) => result.blockers.push(`Vue ${path}: ${reason}`);
77
+ const block = (reason: string) => {
78
+ const message = `Vue ${path}: ${reason}`;
79
+ result.blockers.push(message);
80
+ (result.fileBlockers ??= []).push({ path, reason: message });
81
+ };
82
+ const typeDependencies = new Set<string>();
83
+ const recordTypeDependency = (file: string) => {
84
+ recordRead(file);
85
+ const dependency = relative(context.repoPath, resolve(file)).replace(/\\/g, "/");
86
+ if (dependency === ".." || dependency.startsWith("../") || isAbsolute(dependency)) throw new Error("Vue type dependency escapes repository");
87
+ typeDependencies.add(dependency);
88
+ };
19
89
  try {
20
- const { descriptor, errors } = parse(readFileSync(join(context.repoPath, path), "utf8"), { filename: path });
90
+ const raw = measure("sourceRead", () => readFileSync(join(context.repoPath, path), "utf8"));
91
+ // compiler-sfc discards an empty script block then reports a missing block.
92
+ // Recognize only this exact dependency-free SFC shape, not arbitrary parse errors.
93
+ if (/^\s*<script(?:\s+setup)?(?:\s+lang=["'](?:ts|js)["'])?\s*>\s*<\/script>\s*$/.test(raw)) {
94
+ result.virtualSources.push({ path, source: "export default {};" });
95
+ continue;
96
+ }
97
+ // Dependency extraction uses code, bindings and errors, never source maps.
98
+ const { descriptor, errors } = measure("sfcParse", () => parse(raw, { filename: join(context.repoPath, path), sourceMap: false }));
21
99
  if (errors.length) block("component parse failed");
22
100
  if (descriptor.customBlocks.length) block("custom blocks require a framework plugin");
23
101
  const blocks = [descriptor.script, descriptor.scriptSetup, descriptor.template, ...descriptor.styles].filter((b) => b !== null);
@@ -26,17 +104,39 @@ export const vueAdapter: RepositoryAdapter = {
26
104
  if (b.lang && !["js", "ts", "jsx", "tsx", "html", "css"].includes(b.lang)) block(`unsupported preprocessor ${b.lang}`);
27
105
  }
28
106
  const script = descriptor.script || descriptor.scriptSetup
29
- ? compileScript(descriptor, { id: path }) : undefined;
107
+ ? measure("scriptCompile", () => compileScript(descriptor, { id: path, sourceMap: false, fs: {
108
+ fileExists(file) { recordRead(file); return ts.sys.fileExists(file); },
109
+ readFile(file) {
110
+ recordRead(file);
111
+ recordTypeDependency(file);
112
+ counts.typeReads++;
113
+ return readFileSync(file, "utf8");
114
+ },
115
+ } })) : undefined;
116
+ for (const dependency of script?.deps ?? []) recordTypeDependency(dependency);
117
+ // Imported macro types affect generated runtime props. Retain the files read
118
+ // by the compiler even when the generated script erases their imports.
119
+ for (const dependency of typeDependencies) {
120
+ if (/\.[cm]?[jt]sx?$/.test(dependency)) result.sourcePaths.push(dependency);
121
+ else result.assetPaths.push(dependency);
122
+ result.edges.push({ from: path, to: dependency, kind: "asset" });
123
+ }
30
124
  let source = script?.content ?? "";
31
125
  if (/\bimport\.meta\.glob(?:Eager)?\s*\(/.test(source)) block("glob imports require bundler dependency expansion");
32
126
  if (descriptor.template && !descriptor.template.src && !descriptor.template.lang) {
33
- const template = compileTemplate({
34
- source: descriptor.template.content, filename: path, id: path,
35
- compilerOptions: { bindingMetadata: script?.bindings },
36
- });
127
+ const templateBlock = descriptor.template;
128
+ const template = measure("templateCompile", () => compileTemplate({
129
+ source: templateBlock.content, filename: path, id: path,
130
+ compilerOptions: { bindingMetadata: script?.bindings, sourceMap: false },
131
+ }));
37
132
  if (template.errors.length) block("template compilation failed");
38
133
  // These calls represent dependencies supplied at runtime, outside the import graph.
39
- if (/\b_resolve(?:DynamicComponent|Component|Directive)\s*\(/.test(template.code)) block("runtime component/directive resolution requires full validation");
134
+ const componentCalls = [...template.code.matchAll(/\b_resolveComponent\s*\(\s*(["'])(.*?)\1/g)];
135
+ // Script-setup bindings and native-only templates have no runtime
136
+ // component calls, so an Options API registration scan cannot help.
137
+ const registrations = componentCalls.length ? measure("registrationScan", () => { counts.registrationScans++; return registeredComponents(source); }) : new Set<string>();
138
+ const unresolved = componentCalls.some(match => !registrations.has(match[2]));
139
+ if (unresolved || /\b_resolve(?:DynamicComponent|Directive)\s*\(/.test(template.code)) block("runtime component/directive resolution requires full validation");
40
140
  source += `\n${template.code}`;
41
141
  } else if (descriptor.template) block("external or preprocessed template requires full validation");
42
142
  for (const style of descriptor.styles) {
@@ -44,8 +144,13 @@ export const vueAdapter: RepositoryAdapter = {
44
144
  if (/@import\b|url\s*\(/i.test(style.content)) block("style imports or URLs require full validation");
45
145
  }
46
146
  result.virtualSources.push({ path, source });
47
- } catch {
48
- block("component could not be analyzed");
147
+ } catch (error) {
148
+ const detail = String(error instanceof Error ? error.message : error).replaceAll(context.repoPath, "<repo>").split("\n")[0].slice(0, 240);
149
+ block(`component could not be analyzed: ${detail}`);
150
+ } finally {
151
+ // compiler-sfc caches parsed imported types globally. Do not let a later
152
+ // component or a second analysis reuse stale types or bypass filesystem reads.
153
+ measure("typeCacheInvalidation", () => { for (const dependency of typeDependencies) invalidateTypeCache(join(context.repoPath, dependency)); });
49
154
  }
50
155
  }
51
156
  return result;