@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.
- package/LICENSE +1 -1
- package/README.md +295 -229
- package/action.yml +154 -154
- package/dist-client/src/client/cli.js +213 -95
- package/dist-client/src/client/economics.js +31 -0
- package/dist-client/src/client/full-command.js +46 -0
- package/dist-client/src/client/mcp.js +196 -0
- package/dist-client/src/client/observe.js +40 -9
- package/dist-client/src/client/verify-savings.js +8 -2
- package/docs/agent-adoption-kit.md +96 -0
- package/docs/agent-adoption-targets.md +52 -0
- package/docs/ai-agents.md +27 -3
- package/docs/claude-code.md +3 -2
- package/docs/codex.md +3 -2
- package/docs/copilot.md +4 -4
- package/docs/cursor.md +4 -4
- package/docs/grok.md +3 -2
- package/docs/language-support.md +118 -70
- package/docs/mcp.md +21 -0
- package/docs/npm-adoption.md +6 -0
- package/llms.txt +3 -0
- package/node_modules/@babel/parser/bin/babel-parser.js +0 -0
- package/node_modules/@diffci.com/core/README.md +9 -1
- package/node_modules/@diffci.com/core/dist/cache/economics-context.d.ts +2 -0
- package/node_modules/@diffci.com/core/dist/cache/economics-context.js +21 -0
- package/node_modules/@diffci.com/core/dist/cache/economics-context.js.map +1 -0
- package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.d.ts +5 -0
- package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.js +100 -0
- package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.js.map +1 -0
- package/node_modules/@diffci.com/core/dist/planner/test-command.js +32 -0
- package/node_modules/@diffci.com/core/dist/planner/test-command.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/adapters/go.d.ts +3 -0
- package/node_modules/@diffci.com/core/dist/repo/adapters/go.js +24 -6
- package/node_modules/@diffci.com/core/dist/repo/adapters/go.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/adapters/index.js +2 -1
- package/node_modules/@diffci.com/core/dist/repo/adapters/index.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/adapters/maven.d.ts +3 -0
- package/node_modules/@diffci.com/core/dist/repo/adapters/maven.js +65 -0
- package/node_modules/@diffci.com/core/dist/repo/adapters/maven.js.map +1 -0
- package/node_modules/@diffci.com/core/dist/repo/adapters/types.d.ts +11 -0
- package/node_modules/@diffci.com/core/dist/repo/adapters/types.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/adapters/vue.js +142 -14
- package/node_modules/@diffci.com/core/dist/repo/adapters/vue.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/graph.d.ts +4 -0
- package/node_modules/@diffci.com/core/dist/repo/graph.js +169 -19
- package/node_modules/@diffci.com/core/dist/repo/graph.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/impact.js +22 -5
- package/node_modules/@diffci.com/core/dist/repo/impact.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/repo-config.d.ts +15 -0
- package/node_modules/@diffci.com/core/dist/repo/repo-config.js +13 -2
- package/node_modules/@diffci.com/core/dist/repo/repo-config.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/test-discovery.js +2 -0
- package/node_modules/@diffci.com/core/dist/repo/test-discovery.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/types.d.ts +17 -0
- package/node_modules/@diffci.com/core/dist/repo/vue-scope.d.ts +4 -0
- package/node_modules/@diffci.com/core/dist/repo/vue-scope.js +157 -0
- package/node_modules/@diffci.com/core/dist/repo/vue-scope.js.map +1 -0
- package/node_modules/@diffci.com/core/src/cache/economics-context.ts +19 -0
- package/node_modules/@diffci.com/core/src/cache/vue-analysis-cache.ts +80 -0
- package/node_modules/@diffci.com/core/src/planner/test-command.ts +28 -0
- package/node_modules/@diffci.com/core/src/repo/adapters/go.ts +24 -5
- package/node_modules/@diffci.com/core/src/repo/adapters/index.ts +2 -1
- package/node_modules/@diffci.com/core/src/repo/adapters/maven.ts +38 -0
- package/node_modules/@diffci.com/core/src/repo/adapters/types.ts +5 -0
- package/node_modules/@diffci.com/core/src/repo/adapters/vue.ts +120 -15
- package/node_modules/@diffci.com/core/src/repo/graph.ts +146 -20
- package/node_modules/@diffci.com/core/src/repo/impact.ts +20 -4
- package/node_modules/@diffci.com/core/src/repo/repo-config.ts +20 -2
- package/node_modules/@diffci.com/core/src/repo/test-discovery.ts +2 -0
- package/node_modules/@diffci.com/core/src/repo/types.ts +11 -0
- package/node_modules/@diffci.com/core/src/repo/vue-scope.ts +122 -0
- package/node_modules/nanoid/bin/nanoid.cjs +0 -0
- package/node_modules/typescript/bin/tsc +0 -0
- package/node_modules/typescript/bin/tsserver +0 -0
- package/node_modules/yaml/bin.mjs +0 -0
- 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
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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: "
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|