From db1d637dfcca8d8f7fa047500dfe0027d0d018a7 Mon Sep 17 00:00:00 2001 From: adityankale190895 Date: Tue, 22 Sep 2026 09:16:02 +0530 Subject: [PATCH] Align core with current Vue and Go engine and Maven CI goals --- README.md | 10 +- release-files.json | 15 +- src/cache/economics-context.ts | 19 +++ src/cache/vue-analysis-cache.ts | 80 ++++++++++ src/planner/test-command.ts | 15 +- src/repo/adapters/go.ts | 29 +++- src/repo/adapters/types.ts | 5 + src/repo/adapters/vue.ts | 135 ++++++++++++++-- src/repo/graph.ts | 162 ++++++++++++++++--- src/repo/impact.ts | 24 ++- src/repo/repo-config.ts | 22 ++- src/repo/test-discovery.ts | 4 +- src/repo/types.ts | 9 ++ src/repo/vue-scope.ts | 122 ++++++++++++++ tests/cache/vue-analysis-cache.test.ts | 51 ++++++ tests/repo/language-adapters.test.ts | 16 ++ tests/repo/vue-scope.test.ts | 212 +++++++++++++++++++++++++ 17 files changed, 876 insertions(+), 54 deletions(-) create mode 100644 src/cache/economics-context.ts create mode 100644 src/cache/vue-analysis-cache.ts create mode 100644 src/repo/vue-scope.ts create mode 100644 tests/cache/vue-analysis-cache.test.ts create mode 100644 tests/repo/vue-scope.test.ts diff --git a/README.md b/README.md index 8d79f65..b70da8f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **An open-source engine for understanding changes and planning less CI work conservatively.** Licensed under AGPL-3.0-only. -This is the standalone Core engine. It analyzes local JavaScript/TypeScript repositories, supported Vue components and root Go modules, traces dependency impact, infers GitHub Actions structure and proposes test selections with evidence and full-run fallbacks. It works without a DiffCI account, hosted service or API key. See [the release boundary](docs/release-boundary.md) for support limits. +This is the standalone Core engine. It analyzes local JavaScript/TypeScript repositories, supported Vue components, root Go modules and conventional Maven reactors, traces dependency impact, infers GitHub Actions structure and proposes test selections with evidence and full-run fallbacks. It works without a DiffCI account, hosted service or API key. See [the release boundary](docs/release-boundary.md) for support limits. ## Run locally @@ -21,6 +21,14 @@ The target must be a clean Git repository root checked out at `--head`. Fetch th **Plans are advisory.** This release does not execute, skip or cancel CI jobs. `SKIP_CANDIDATE` is a candidate, not permission to bypass a check. Keep full CI authoritative while evaluating Core. Missing command synthesis, unsupported configuration and incomplete evidence must not be treated as an empty test suite. Static analysis is not proof that a test can safely be omitted. +For Maven jobs whose CI runs a lifecycle goal or profiles other than `test`, declare them in `diffci.json` (or the `diffci` key in `package.json`): + +```json +{ "maven": { "goal": "verify", "profiles": ["run-its"] } } +``` + +This changes the proposed command, such as `mvn -pl tools -am verify -P run-its`; Core still does not run it. Confirm the command against the repository's CI workflow before using it for execution. + ## What is included | Area | Implementation | diff --git a/release-files.json b/release-files.json index 4c5b8bf..1974eb3 100644 --- a/release-files.json +++ b/release-files.json @@ -4,19 +4,21 @@ ".github/workflows/ci.yml", ".gitignore", "CONTRIBUTING.md", - "LICENSE", - "NOTICE", - "README.md", "docs/release-boundary.md", "funding.json", + "LICENSE", + "NOTICE", "package-lock.json", "package.json", + "README.md", "release-files.json", "scripts/audit-boundary.mjs", "scripts/benchmark.ts", "scripts/run-tests.mjs", "src/analyze.ts", + "src/cache/economics-context.ts", "src/cache/graph-cache.ts", + "src/cache/vue-analysis-cache.ts", "src/ci-inference/causal.ts", "src/ci-inference/evidence.ts", "src/ci-inference/expression.ts", @@ -38,13 +40,13 @@ "src/planner/task-registry.ts", "src/planner/test-command.ts", "src/planner/types.ts", - "src/repo/analyzer.ts", "src/repo/adapters/go-test.ts", "src/repo/adapters/go.ts", - "src/repo/adapters/maven.ts", "src/repo/adapters/index.ts", + "src/repo/adapters/maven.ts", "src/repo/adapters/types.ts", "src/repo/adapters/vue.ts", + "src/repo/analyzer.ts", "src/repo/graph.ts", "src/repo/impact-types.ts", "src/repo/impact.ts", @@ -55,6 +57,7 @@ "src/repo/test-fixture-ownership.ts", "src/repo/test-framework.ts", "src/repo/types.ts", + "src/repo/vue-scope.ts", "src/research/baseline/matcher.ts", "src/research/baseline/path-baseline.ts", "src/research/baseline/registry.ts", @@ -62,6 +65,7 @@ "src/research/baseline/workflow-parser.ts", "tests/analyze.test.ts", "tests/cache/graph-cache.test.ts", + "tests/cache/vue-analysis-cache.test.ts", "tests/ci-inference/causal.test.ts", "tests/ci-inference/dependency-time-boxing.test.ts", "tests/ci-inference/executability.test.ts", @@ -86,6 +90,7 @@ "tests/repo/test-discovery.test.ts", "tests/repo/test-fixture-ownership.test.ts", "tests/repo/test-framework.test.ts", + "tests/repo/vue-scope.test.ts", "tests/research/baseline/matcher.test.ts", "tests/research/baseline/test-activity.test.ts", "tests/research/baseline/workflow-parser.test.ts", diff --git a/src/cache/economics-context.ts b/src/cache/economics-context.ts new file mode 100644 index 0000000..1b9b4f3 --- /dev/null +++ b/src/cache/economics-context.ts @@ -0,0 +1,19 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { readRepositoryConfig } from "../repo/repo-config.js"; + +/** Files that determine whether a cached Vue analysis still matches the checkout configuration. */ +export function economicsContext(repoPath: string): string { + const scope = readRepositoryConfig(repoPath).vue; + const paths = new Set(["diffci.json", "package.json", "tsconfig.json", "go.mod", "go.sum", "go.work", "go.work.sum", "pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock", "bun.lockb", ...["ts", "js", "mts", "mjs", "cts", "cjs"].map(ext => `vitest.config.${ext}`)]); + if (scope) for (const path of ["package.json", "tsconfig.json", scope.testConfig]) paths.add(`${scope.packageRoot}/${path}`); + const hash = createHash("sha256").update(JSON.stringify([process.platform, process.arch, process.version])); + for (const path of [...paths].sort()) { + hash.update(JSON.stringify(path)); + const file = join(repoPath, path); + hash.update(existsSync(file) ? readFileSync(file) : ""); + hash.update("\0"); + } + return hash.digest("hex"); +} diff --git a/src/cache/vue-analysis-cache.ts b/src/cache/vue-analysis-cache.ts new file mode 100644 index 0000000..a7c910e --- /dev/null +++ b/src/cache/vue-analysis-cache.ts @@ -0,0 +1,80 @@ +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { economicsContext } from "./economics-context.js"; +import { vueAdapter } from "../repo/adapters/vue.js"; +import { contribution, type AdapterContext, type AdapterContribution } from "../repo/adapters/types.js"; + +const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex"); +function physical(path: string): string { + if (existsSync(path)) return realpathSync(path); + return resolve(physical(dirname(path)), relative(dirname(path), path)); +} +function fingerprint(path: string): string { + if (!existsSync(path)) return "absent"; + return JSON.stringify([realpathSync(path), statSync(path).isFile() ? digest(readFileSync(path)) : "directory"]); +} + +/** Trusted runner-local cache, optional and never a source of test-selection authority. + * Every hit validates source, context and all compiler filesystem probes. Graph resolution + * and selection are rebuilt. Any unreadable/corrupt cache is an ordinary analysis miss. */ +export function analyzeVueCached(context: AdapterContext, directory: string, version: string): AdapterContribution { + let cacheDir: string; + let namespace: string; + try { + cacheDir = physical(resolve(directory)); + const rel = relative(realpathSync(context.repoPath), cacheDir); + if (!rel || (!(rel === ".." || rel.startsWith("../") || rel.startsWith("..\\")) && !isAbsolute(rel))) return vueAdapter.analyze(context); + namespace = digest(JSON.stringify(["vue-analysis-v1", version, vueAdapter.version, realpathSync(context.repoPath), economicsContext(context.repoPath), context.files])); + } catch { return vueAdapter.analyze(context); } + const result = contribution(vueAdapter); + const phasesMs: Record = {}; + const counts: Record = { cacheHits: 0, cacheMisses: 0, cacheWriteFailures: 0 }; + result.performance = { phasesMs, counts }; + for (const path of context.files.filter(file => file.endsWith(".vue"))) { + const started = performance.now(); + const file = join(cacheDir, `${digest(path)}.json`); + let key = ""; + let item: AdapterContribution | undefined; + try { + key = digest(JSON.stringify([namespace, path, fingerprint(join(context.repoPath, path))])); + const envelope = JSON.parse(readFileSync(file, "utf8")); + if (envelope.key === key && typeof envelope.payload === "string" && digest(envelope.payload) === envelope.sha256) { + const data = JSON.parse(envelope.payload); + if (Array.isArray(data.probes) && data.probes.every((probe: [string, string]) => Array.isArray(probe) && probe.length === 2 && typeof probe[0] === "string" && fingerprint(probe[0]) === probe[1])) item = data.result; + } + } catch { /* unreadable or stale entry: rebuild */ } + phasesMs.cacheReadValidate = (phasesMs.cacheReadValidate ?? 0) + performance.now() - started; + if (item) counts.cacheHits++; + else { + counts.cacheMisses++; + const probes = new Map(); + let cacheable = true; + item = vueAdapter.analyze({ ...context, vueComponentPaths: [path], recordVueRead(file) { + // TS config expansion can read extended configs through its own system host. + // Until every such input is recorded, rebuild compiler-assisted components. + if (file.endsWith(".json")) cacheable = false; + try { probes.set(resolve(file), fingerprint(file)); } catch { cacheable = false; } + } }); + if (key && cacheable) { + const writeStart = performance.now(); + try { + const { performance: _performance, ...cachedResult } = item; + const payload = JSON.stringify({ probes: [...probes], result: cachedResult }); + mkdirSync(cacheDir, { recursive: true }); + const temporary = `${file}.${randomUUID()}.tmp`; + writeFileSync(temporary, JSON.stringify({ key, payload, sha256: digest(payload) })); + renameSync(temporary, file); + } catch { counts.cacheWriteFailures++; } + phasesMs.cacheWrite = (phasesMs.cacheWrite ?? 0) + performance.now() - writeStart; + } + } + for (const field of ["sourcePaths", "assetPaths", "edges", "virtualSources", "testFiles"] as const) (result[field] as unknown[]).push(...item[field]); + result.blockers.push(...item.blockers.filter(blocker => !result.blockers.includes(blocker))); + (result.fileBlockers ??= []).push(...(item.fileBlockers ?? [])); + Object.assign(result.testPackages, item.testPackages); + for (const [name, value] of Object.entries(item.performance?.phasesMs ?? {})) phasesMs[name] = (phasesMs[name] ?? 0) + value; + for (const [name, value] of Object.entries(item.performance?.counts ?? {})) counts[name] = (counts[name] ?? 0) + value; + } + return result; +} diff --git a/src/planner/test-command.ts b/src/planner/test-command.ts index 7f93a4d..1fac871 100644 --- a/src/planner/test-command.ts +++ b/src/planner/test-command.ts @@ -133,6 +133,17 @@ export function planSelectiveTestCommands( ): SelectiveTestCommandPlan { const blockers = profile.adapterBlockers ?? profile.adapters?.flatMap((adapter) => adapter.blockers) ?? []; if (blockers.length) return { commands: [], groups: [], unroutedPaths: [...selectedPaths], refusalReason: blockers.join("; ") }; + if (profile.vueScope) { + const scope = profile.vueScope; + const known = new Set(profile.testFilePaths); + const unclaimed = selectedPaths.filter(path => !known.has(path)); + if (unclaimed.length || profile.packageManager !== "pnpm") return { commands: [], groups: [], unroutedPaths: [...unclaimed], refusalReason: "Scoped Vitest execution requires pnpm and verified package test paths" }; + if (!selectedPaths.length) return { commands: [], groups: [], unroutedPaths: [] }; + const paths = [...selectedPaths].sort(); + const local = paths.map(path => scope.packageRoot === "." ? path : path.slice(scope.packageRoot.length + 1)); + const commandSpec: CommandSpec = { executable: "pnpm", args: ["--dir", scope.packageRoot, "exec", "vitest", "run", "--config", scope.testConfig, ...local] }; + return { commands: [commandSpec], groups: [{ runnerId: `vitest:${scope.packageRoot}/${scope.testConfig}`, label: "Declared Vue package suite", paths, commandSpec }], unroutedPaths: [] }; + } const goPaths = selectedPaths.filter((path) => path.endsWith(".go")); if (goPaths.length) { const packages = profile.goTestPackages ?? {}; @@ -161,7 +172,9 @@ export function planSelectiveTestCommands( if (targets.some((target) => target !== "." && (target.startsWith("/") || target.split("/").includes("..") || /[\\\r\n]/.test(target)))) { return { commands: [], groups: [], unroutedPaths: javaPaths, refusalReason: "Invalid Maven reactor module target" }; } - const args = targets.length === 1 && targets[0] === "." ? ["test"] : ["-pl", targets.join(","), "-am", "test"]; + const maven = profile.diffciConfig?.maven; + const goal = maven?.goal ?? "test"; + const args = [...(targets.length === 1 && targets[0] === "." ? [] : ["-pl", targets.join(","), "-am"]), goal, ...(maven?.profiles?.length ? ["-P", maven.profiles.join(",")] : [])]; const commandSpec: CommandSpec = { executable: "mvn", args }; const group: SelectiveTestCommandGroup = { runnerId: "maven:surefire", label: "Maven reactor tests", paths: [...javaPaths].sort(), commandSpec }; return { commands: [commandSpec], groups: [group], unroutedPaths: [] }; diff --git a/src/repo/adapters/go.ts b/src/repo/adapters/go.ts index 1dcc96e..8536439 100644 --- a/src/repo/adapters/go.ts +++ b/src/repo/adapters/go.ts @@ -14,6 +14,7 @@ interface GoPackage { DepsErrors?: unknown[]; Incomplete?: boolean; GoFiles?: string[]; + IgnoredGoFiles?: string[]; CgoFiles?: string[]; TestGoFiles?: string[]; XTestGoFiles?: string[]; @@ -59,6 +60,11 @@ function internalPath(root: string, path: string): string | undefined { return rel === ".." || rel.startsWith("../") || isAbsolute(rel) ? undefined : rel; } +/** go ./... excludes these names; edits still require full CI (for example testdata reads). */ +export function isGoDiscoveryIgnoredPath(path: string): boolean { + return path.split("/").some(part => part === "testdata" || part.startsWith("_") || part.startsWith(".")); +} + export function analyzeGoMetadata(context: AdapterContext, output: string): AdapterContribution { const result = contribution(goAdapter); const all = parseGoList(output); @@ -68,7 +74,10 @@ export function analyzeGoMetadata(context: AdapterContext, output: string): Adap const anchors = new Map(); const members = new Map(); for (const pkg of packages) { - const files = [...(pkg.GoFiles ?? []), ...(pkg.CgoFiles ?? []), ...(pkg.TestGoFiles ?? []), ...(pkg.XTestGoFiles ?? [])]; + // Associate inactive files with their package conservatively. They are not runnable + // tests, but edits (including build constraints) must still select that package and + // its dependents. Unaccounted files remain a global blocker below. + const files = [...(pkg.GoFiles ?? []), ...(pkg.CgoFiles ?? []), ...(pkg.TestGoFiles ?? []), ...(pkg.XTestGoFiles ?? []), ...(pkg.IgnoredGoFiles ?? [])]; const paths = files.map((file) => internalPath(context.repoPath, resolve(pkg.Dir, file))); if (paths.some((p) => p === undefined)) { result.blockers.push("Go package contains files outside the repository"); continue; } const sources = paths as string[]; @@ -110,16 +119,19 @@ export function analyzeGoMetadata(context: AdapterContext, output: string): Adap if (!result.sourcePaths.length) result.blockers.push("Go analysis found no local packages"); // Ignored files/build tags, generation and testdata can change the runnable universe. const modeled = new Set([...result.sourcePaths, ...result.assetPaths]); - if (context.files.some((file) => file.endsWith(".go") && !modeled.has(file))) result.blockers.push("Go files outside the active build context require full validation"); + const unmodeled = context.files.filter(file => file.endsWith(".go") && !modeled.has(file) && !isGoDiscoveryIgnoredPath(file)); + if (unmodeled.length) result.blockers.push(`Go files outside the active build context require full validation: ${unmodeled.slice(0, 20).join(", ")}`); return result; } export const goAdapter: RepositoryAdapter = { - id: "go", version: "1", kind: "language", + id: "go", version: "3", kind: "language", detect: ({ files }) => files.some((file) => file === "go.mod" || file.endsWith(".go")), analyze(context) { const failure = contribution(this); - if (!context.files.includes("go.mod") || context.files.some((f) => f === "go.work" || f.endsWith("/go.mod"))) { + const nestedRoots = context.files.filter(f => f.endsWith("/go.mod")).map(f => f.slice(0, -"go.mod".length)); + const scoped = context.profile.diffciConfig?.go?.scope === "root-module"; + if (!context.files.includes("go.mod") || context.files.includes("go.work") || (nestedRoots.length && !scoped)) { failure.blockers.push("Go support requires one root go.mod; workspaces and nested modules require full validation"); return failure; } @@ -140,7 +152,14 @@ export const goAdapter: RepositoryAdapter = { env, stdio: ["ignore", "pipe", "pipe"], }); - const result = analyzeGoMetadata(context, output); + const excluded = (file: string) => nestedRoots.some(root => file.startsWith(root)); + const scopedContext = scoped ? { ...context, files: context.files.filter(file => !excluded(file)) } : context; + const result = analyzeGoMetadata(scopedContext, output); + if (scoped) { + context.profile.goExcludedModuleRoots = nestedRoots; + // A local replacement can make an excluded module part of the root module's build. + if (parseGoList(output).some(pkg => pkg.Module?.Replace?.Dir)) result.blockers.push("Go root-module scope with local replacements requires full validation"); + } result.executionEnv = { GOOS: buildEnv.GOOS, GOARCH: buildEnv.GOARCH, CGO_ENABLED: buildEnv.CGO_ENABLED, GOFLAGS: "" }; return result; } catch { diff --git a/src/repo/adapters/types.ts b/src/repo/adapters/types.ts index f8f6eef..fd8963f 100644 --- a/src/repo/adapters/types.ts +++ b/src/repo/adapters/types.ts @@ -5,6 +5,9 @@ export interface AdapterContext { repoPath: string; files: readonly string[]; profile: RepositoryProfile; + vueComponentPaths?: readonly string[]; + recordVueRead?: (path: string) => void; + vueAnalysisSession?: object; } export interface AdapterContribution { @@ -20,6 +23,8 @@ export interface AdapterContribution { executionEnv?: Record; /** Global blockers: missing edges cannot be dismissed using graph reachability. */ blockers: string[]; + fileBlockers?: Array<{ path: string; reason: string }>; + performance?: { phasesMs: Record; counts: Record }; } export interface RepositoryAdapter { diff --git a/src/repo/adapters/vue.ts b/src/repo/adapters/vue.ts index de4a071..8f665d5 100644 --- a/src/repo/adapters/vue.ts +++ b/src/repo/adapters/vue.ts @@ -1,23 +1,101 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { compileScript, compileTemplate, parse } from "@vue/compiler-sfc"; +import { existsSync, readFileSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { createRequire } from "node:module"; +import ts from "typescript"; +const loadCompiler = createRequire(import.meta.url); +let previousSession: object | undefined; +const compilerConfigs = new Set(); + +/** Only direct imports registered in a literal component options object are provable. */ +function registeredComponents(source: string): Set { + const file = ts.createSourceFile("component.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const imports = new Set(); + const factories = new Set(); + for (const statement of file.statements) { + if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly) continue; + const clause = statement.importClause; + if (clause?.name) imports.add(clause.name.text); + if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) { + for (const binding of clause.namedBindings.elements) { + if (binding.isTypeOnly) continue; + imports.add(binding.name.text); + if (ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "vue" && (binding.propertyName ?? binding.name).text === "defineComponent") factories.add(binding.name.text); + } + } + } + const names = new Set(); + const exp = file.statements.find(ts.isExportAssignment); + if (!exp || exp.isExportEquals) return names; + let value = exp.expression; + if (ts.isCallExpression(value) && ts.isIdentifier(value.expression) && factories.has(value.expression.text) && value.arguments.length === 1) value = value.arguments[0]; + if (!ts.isObjectLiteralExpression(value) || value.properties.some(p => ts.isSpreadAssignment(p) || (p.name && ts.isComputedPropertyName(p.name)))) return names; + const registrations = value.properties.filter(p => p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === "components"); + if (registrations.length !== 1 || !ts.isPropertyAssignment(registrations[0]) || !ts.isObjectLiteralExpression(registrations[0].initializer)) return names; + for (const property of registrations[0].initializer.properties) { + if (ts.isShorthandPropertyAssignment(property) && imports.has(property.name.text)) names.add(property.name.text); + 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); + else return new Set(); + } + return new Set([...names].flatMap(name => [name, name.replace(/\B([A-Z])/g, "-$1").toLowerCase()])); +} import { contribution, type RepositoryAdapter } from "./types.js"; /** Explicit Vue SFC imports. Runtime component registries and preprocessors require full CI. */ export const vueAdapter: RepositoryAdapter = { - id: "vue", version: "1", kind: "framework", + id: "vue", version: "7", kind: "framework", detect: ({ files }) => files.some((file) => file.endsWith(".vue")), analyze(context) { + const phasesMs: Record = {}; + const counts: Record = { components: 0, typeReads: 0, registrationScans: 0 }; + const measure = (name: string, work: () => T): T => { + const start = performance.now(); + try { return work(); } finally { phasesMs[name] = (phasesMs[name] ?? 0) + performance.now() - start; } + }; + // Go and scoped TS-only packages do not need to initialize Vue's compiler/Babel + // dependency tree. Keep the same synchronous adapter contract, loading it on use. + const { compileScript, compileTemplate, invalidateTypeCache, parse, registerTS } = measure("compilerLoad", () => loadCompiler("@vue/compiler-sfc")) as typeof import("@vue/compiler-sfc"); + registerTS(() => ts); + const session = context.vueAnalysisSession ?? {}; + if (session !== previousSession) { + for (const file of compilerConfigs) invalidateTypeCache(file); + compilerConfigs.clear(); + previousSession = session; + } + const recordRead = (file: string) => { + if (file.endsWith(".json")) compilerConfigs.add(file); + context.recordVueRead?.(file); + }; const result = contribution(this); + result.performance = { phasesMs, counts }; const dependencies = [...context.profile.packageJson.dependencies, ...context.profile.packageJson.devDependencies]; if (dependencies.includes("nuxt") || context.files.some((file) => /(?:^|\/)nuxt\.config\./.test(file))) { result.blockers.push("Nuxt implicit routes and auto-imports require a dedicated framework adapter"); } - for (const path of context.files.filter((file) => file.endsWith(".vue"))) { + for (const path of context.vueComponentPaths ?? context.files.filter((file) => file.endsWith(".vue"))) { + counts.components++; result.sourcePaths.push(path); - const block = (reason: string) => result.blockers.push(`Vue ${path}: ${reason}`); + const block = (reason: string) => { + const message = `Vue ${path}: ${reason}`; + result.blockers.push(message); + (result.fileBlockers ??= []).push({ path, reason: message }); + }; + const typeDependencies = new Set(); + const recordTypeDependency = (file: string) => { + recordRead(file); + const dependency = relative(context.repoPath, resolve(file)).replace(/\\/g, "/"); + if (dependency === ".." || dependency.startsWith("../") || isAbsolute(dependency)) throw new Error("Vue type dependency escapes repository"); + typeDependencies.add(dependency); + }; try { - const { descriptor, errors } = parse(readFileSync(join(context.repoPath, path), "utf8"), { filename: path }); + const raw = measure("sourceRead", () => readFileSync(join(context.repoPath, path), "utf8")); + // compiler-sfc discards an empty script block then reports a missing block. + // Recognize only this exact dependency-free SFC shape, not arbitrary parse errors. + if (/^\s*\s*<\/script>\s*$/.test(raw)) { + result.virtualSources.push({ path, source: "export default {};" }); + continue; + } + // Dependency extraction uses code, bindings and errors, never source maps. + const { descriptor, errors } = measure("sfcParse", () => parse(raw, { filename: join(context.repoPath, path), sourceMap: false })); if (errors.length) block("component parse failed"); if (descriptor.customBlocks.length) block("custom blocks require a framework plugin"); const blocks = [descriptor.script, descriptor.scriptSetup, descriptor.template, ...descriptor.styles].filter((b) => b !== null); @@ -26,17 +104,39 @@ export const vueAdapter: RepositoryAdapter = { if (b.lang && !["js", "ts", "jsx", "tsx", "html", "css"].includes(b.lang)) block(`unsupported preprocessor ${b.lang}`); } const script = descriptor.script || descriptor.scriptSetup - ? compileScript(descriptor, { id: path }) : undefined; + ? measure("scriptCompile", () => compileScript(descriptor, { id: path, sourceMap: false, fs: { + fileExists(file) { recordRead(file); return ts.sys.fileExists(file); }, + readFile(file) { + recordRead(file); + recordTypeDependency(file); + counts.typeReads++; + return readFileSync(file, "utf8"); + }, + } })) : undefined; + for (const dependency of script?.deps ?? []) recordTypeDependency(dependency); + // Imported macro types affect generated runtime props. Retain the files read + // by the compiler even when the generated script erases their imports. + for (const dependency of typeDependencies) { + if (/\.[cm]?[jt]sx?$/.test(dependency)) result.sourcePaths.push(dependency); + else result.assetPaths.push(dependency); + result.edges.push({ from: path, to: dependency, kind: "asset" }); + } let source = script?.content ?? ""; if (/\bimport\.meta\.glob(?:Eager)?\s*\(/.test(source)) block("glob imports require bundler dependency expansion"); if (descriptor.template && !descriptor.template.src && !descriptor.template.lang) { - const template = compileTemplate({ - source: descriptor.template.content, filename: path, id: path, - compilerOptions: { bindingMetadata: script?.bindings }, - }); + const templateBlock = descriptor.template; + const template = measure("templateCompile", () => compileTemplate({ + source: templateBlock.content, filename: path, id: path, + compilerOptions: { bindingMetadata: script?.bindings, sourceMap: false }, + })); if (template.errors.length) block("template compilation failed"); // These calls represent dependencies supplied at runtime, outside the import graph. - if (/\b_resolve(?:DynamicComponent|Component|Directive)\s*\(/.test(template.code)) block("runtime component/directive resolution requires full validation"); + const componentCalls = [...template.code.matchAll(/\b_resolveComponent\s*\(\s*(["'])(.*?)\1/g)]; + // Script-setup bindings and native-only templates have no runtime + // component calls, so an Options API registration scan cannot help. + const registrations = componentCalls.length ? measure("registrationScan", () => { counts.registrationScans++; return registeredComponents(source); }) : new Set(); + const unresolved = componentCalls.some(match => !registrations.has(match[2])); + if (unresolved || /\b_resolve(?:DynamicComponent|Directive)\s*\(/.test(template.code)) block("runtime component/directive resolution requires full validation"); source += `\n${template.code}`; } else if (descriptor.template) block("external or preprocessed template requires full validation"); for (const style of descriptor.styles) { @@ -44,8 +144,13 @@ export const vueAdapter: RepositoryAdapter = { if (/@import\b|url\s*\(/i.test(style.content)) block("style imports or URLs require full validation"); } result.virtualSources.push({ path, source }); - } catch { - block("component could not be analyzed"); + } catch (error) { + const detail = String(error instanceof Error ? error.message : error).replaceAll(context.repoPath, "").split("\n")[0].slice(0, 240); + block(`component could not be analyzed: ${detail}`); + } finally { + // compiler-sfc caches parsed imported types globally. Do not let a later + // component or a second analysis reuse stale types or bypass filesystem reads. + measure("typeCacheInvalidation", () => { for (const dependency of typeDependencies) invalidateTypeCache(join(context.repoPath, dependency)); }); } } return result; diff --git a/src/repo/graph.ts b/src/repo/graph.ts index 21e2990..12bcce1 100644 --- a/src/repo/graph.ts +++ b/src/repo/graph.ts @@ -1,10 +1,11 @@ -import { existsSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; import { createTestFileMatcher, DEFAULT_TEST_FILE_MATCHER, testFileMatcherForProfile, type TestFileMatcher } from "./test-discovery.js"; import { isBuiltin } from "node:module"; import { dirname, extname, join, normalize, relative, resolve, sep } from "node:path"; import ts from "typescript"; import { adapterFiles, REPOSITORY_ADAPTERS } from "./adapters/index.js"; import { analyzeRepository, type AnalyzeRepositoryOptions } from "./analyzer.js"; +import { applyVueScope, inVuePackage } from "./vue-scope.js"; import type { DependencyEdge, DependencyEdgeKind, @@ -382,6 +383,7 @@ function createProgram( repoPath: string, fallbackSourceRoots: SourceRoot[] = [], additionalSources: string[] = [], + syntaxOnly = false, ): { program: ts.Program; options: ts.CompilerOptions; @@ -521,7 +523,10 @@ function createProgram( } const program = ts.createProgram({ rootNames: fileNames, - options, + // Scoped analysis already inventories every implementation file. It extracts + // syntax/import edges, never asks TypeScript for semantic diagnostics. Keep + // the original options below for explicit module resolution. + options: syntaxOnly ? { ...options, noResolve: true, noLib: true, types: [] } : options, configFileParsingDiagnostics, }); @@ -600,23 +605,60 @@ function findAssetCandidate( return undefined; } -export interface BuildDependencyGraphOptions extends AnalyzeRepositoryOptions {} +export interface BuildDependencyGraphOptions extends AnalyzeRepositoryOptions { + vueAnalysisCache?: { directory: string; version: string }; +} export async function buildDependencyGraph( options: BuildDependencyGraphOptions = {}, ): Promise { const start = process.hrtime.bigint(); + const phasesMs: Record = {}; + let phaseStart = start; + const markPhase = (name: string) => { + const now = process.hrtime.bigint(); + phasesMs[name] = Number(now - phaseStart) / 1_000_000; + phaseStart = now; + }; const repoPath = options.repoPath ? resolve(options.repoPath) : process.cwd(); const profile = analyzeRepository(options); - const files = adapterFiles(repoPath, options.excludeDirs); - const context = { repoPath, files, profile }; - const contributions = REPOSITORY_ADAPTERS.filter((adapter) => adapter.detect(context)).map((adapter) => adapter.analyze(context)); - const adapterBlockers = contributions.flatMap((item) => item.blockers); + markPhase("repositoryDiscovery"); + const scopeBlockers = applyVueScope(repoPath, profile); + markPhase("scopeDiscovery"); + const scope = profile.vueScope; + const physicalRepoRoot = scope ? realpathSync.native(repoPath) : ""; + const physicalPackageRoot = scope ? realpathSync.native(join(repoPath, scope.packageRoot)) : ""; + const scopePathChecks = new Map(); + const outsideScope = (path: string): boolean => { + if (!scope) return false; + const cached = scopePathChecks.get(path); + if (cached !== undefined) return cached; + const lexicalOutside = !inVuePackage(path, scope.packageRoot) && !path.split("/").includes("node_modules"); + let outside = lexicalOutside; + if (!outside && existsSync(join(repoPath, path))) { + const physical = realpathSync.native(join(repoPath, path)); + const packageRelative = relative(physicalPackageRoot, physical).replace(/\\/g, "/"); + const repositoryRelative = relative(physicalRepoRoot, physical).replace(/\\/g, "/"); + outside = (packageRelative === ".." || packageRelative.startsWith("../") || /^[A-Za-z]:|^\//.test(packageRelative)) && + !(!repositoryRelative.startsWith("../") && !/^[A-Za-z]:|^\//.test(repositoryRelative) && (repositoryRelative.startsWith("node_modules/") || repositoryRelative.includes("/node_modules/"))); + } + scopePathChecks.set(path, outside); + return outside; + }; + const files = adapterFiles(repoPath, options.excludeDirs).filter(path => !scope || inVuePackage(path, scope.packageRoot)); + markPhase("adapterInventory"); + const context = { repoPath, files, profile, vueAnalysisSession: {} }; + const cachedVue = options.vueAnalysisCache ? (await import("../cache/vue-analysis-cache.js")).analyzeVueCached : undefined; + const contributions = REPOSITORY_ADAPTERS.filter((adapter) => adapter.detect(context)).map((adapter) => adapter.id === "vue" && cachedVue && options.vueAnalysisCache + ? cachedVue(context, options.vueAnalysisCache.directory, options.vueAnalysisCache.version) : adapter.analyze(context)); + markPhase("adapters"); + const adapterBlockers = [...scopeBlockers, ...contributions.flatMap((item) => item.blockers)]; + if (profile.diffciConfig?.configurationError && !adapterBlockers.includes(profile.diffciConfig.configurationError)) adapterBlockers.push(profile.diffciConfig.configurationError); if (contributions.some((item) => item.id === "go") && files.some((file) => /\.(?:[cm]?[jt]sx?|vue)$/.test(file))) { adapterBlockers.push("Mixed Go/JavaScript repositories require explicit cross-language dependencies; full validation required"); } - if (contributions.length && files.some((file) => /\.(?:py|rs|cs|svelte|astro)$/.test(file))) { + if (contributions.length && files.some((file) => /\.(?:py|rs|cs|svelte|astro)$/.test(file) || !contributions.some((item) => item.id === "maven") && /\.(?:java|kt)$/.test(file))) { adapterBlockers.push("Unmodeled languages alongside an adapter require full validation"); } profile.adapters = contributions.map(({ id, version, blockers }) => ({ id, version, blockers })); @@ -631,15 +673,14 @@ export async function buildDependencyGraph( } const entryPointPaths = new Set(profile.entryPoints.map((e) => e.path)); - const vueSources = contributions.some((item) => item.id === "vue") + const vueSources = scope || contributions.some((item) => item.id === "vue") ? files.filter((file) => /\.[cm]?[jt]sx?$/.test(file)).map((file) => join(repoPath, file)) : []; - // Compiler include/exclude controls typechecking, not the runner's test universe. Parse every - // discovered JS/TS test so source changes can reach tests outside the compiler's root files. - // Adding those tests only as leaf nodes silently loses their dependency edges (ky, 2026-09-19). + // Runner tests excluded by tsconfig still need their import edges in the graph. const testSources = profile.testFilePaths .filter((file) => /\.[cm]?[jt]sx?$/.test(file)) .map((file) => join(repoPath, file)); - let { program, options: compilerOptions, resolvedViaProjectReferences } = createProgram(repoPath, profile.sourceRoots, [...vueSources, ...testSources]); + let { program, options: compilerOptions, resolvedViaProjectReferences } = createProgram(scope ? join(repoPath, scope.packageRoot) : repoPath, scope ? [] : profile.sourceRoots, [...vueSources, ...testSources], Boolean(scope)); + markPhase("typescriptProgram"); let moduleResolutionCache = ts.createModuleResolutionCache( repoPath, (x) => x, @@ -648,7 +689,7 @@ export async function buildDependencyGraph( const sourceFiles = program .getSourceFiles() - .filter((sf) => sf.fileName && !sf.fileName.endsWith(".d.ts")); + .filter((sf) => sf.fileName && !sf.fileName.endsWith(".d.ts") && (!scope || inVuePackage(toRelativeInternal(repoPath, sf.fileName) ?? "", scope.packageRoot))); for (const item of contributions) { for (const virtual of item.virtualSources) { sourceFiles.push(ts.createSourceFile(join(repoPath, virtual.path), virtual.source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)); @@ -711,7 +752,8 @@ export async function buildDependencyGraph( addEdge(importerRel, assetRel, "asset"); } - const filesParsed = sourceFiles.length; + let filesParsed = sourceFiles.length; + let followedImplementationFiles = 0; for (const sf of sourceFiles) { const importerRel = toRelativeInternal(repoPath, sf.fileName); @@ -719,6 +761,7 @@ export async function buildDependencyGraph( if (isExcludedPath(importerRel, options.excludeDirs ?? [])) continue; const refs = extractImportRefs(sf); + if (scope && sf.referencedFiles.length) adapterBlockers.push(`Triple-slash file references in the scoped suite require full validation: ${importerRel}`); for (const ref of refs) { if (!ref.specifier) { recordUnresolved(importerRel, ref, "empty specifier"); @@ -782,8 +825,34 @@ export async function buildDependencyGraph( const resolved = resolution.resolvedModule.resolvedFileName; const targetRel = toRelativeInternal(repoPath, resolved); + // The generic internal-path helper intentionally hides root node_modules. + // Scope validation must still follow those paths to catch workspace symlinks. + const scopeTarget = scope ? relative(physicalRepoRoot, realpathSync.native(resolved)).replace(/\\/g, "/") : targetRel; + if (scopeTarget && outsideScope(scopeTarget)) { + adapterBlockers.push(`Vue dependency crosses the declared package boundary: ${importerRel} -> ${scopeTarget}`); + continue; + } + if (scope && resolution.resolvedModule.isExternalLibraryImport && (!targetRel || targetRel.split("/").includes("node_modules"))) { + recordReference("external-package", importerRel, ref); + continue; + } if (targetRel && isSourceFileName(resolved)) { + if (scope && !resolved.endsWith(".d.ts") && !internalSourcePaths.has(targetRel)) { + // A real import can reach generated implementation excluded from the + // initial walk. Parse it (and its imports) rather than silently dropping + // the edge or loading every TypeScript declaration library again. + try { + if (isExcludedPath(targetRel, options.excludeDirs ?? []) || followedImplementationFiles >= 500 || statSync(resolved).size > 5 * 1024 * 1024) throw new Error("excluded or exceeds parse budget"); + const followed = ts.createSourceFile(resolved, readFileSync(resolved, "utf8"), ts.ScriptTarget.Latest, true); + if ((followed as unknown as { parseDiagnostics?: unknown[] }).parseDiagnostics?.length) throw new Error("invalid implementation syntax"); + sourceFiles.push(followed); + followedImplementationFiles++; + } catch { + adapterBlockers.push(`Resolved implementation cannot be included in the scoped source inventory: ${targetRel}`); + continue; + } + } internalSourcePaths.add(targetRel); addEdge(importerRel, targetRel, ref.kind); recordReference("internal-source", importerRel, ref); @@ -809,13 +878,65 @@ export async function buildDependencyGraph( } } + // Only a verified scoped suite can establish that a component is not executed. + // Every test and setup/config root must actually have been parsed, not merely + // added as a leaf test node. Unknown reachable components still block the suite. + if (scope && scopeBlockers.length === 0 && profile.testFilePaths.length) { + const roots = [...profile.testFilePaths, ...(profile.vueSetupPaths ?? [])]; + const parsedPaths = new Set(sourceFiles.map(file => toRelativeInternal(repoPath, file.fileName)).filter(Boolean)); + if (roots.every(path => parsedPaths.has(path))) { + const outgoing = new Map(); + for (const edge of edges) outgoing.set(edge.from, [...(outgoing.get(edge.from) ?? []), edge.to]); + const reachable = new Set(roots); const pending = [...roots]; + while (pending.length) for (const target of outgoing.get(pending.pop()!) ?? []) if (!reachable.has(target)) { reachable.add(target); pending.push(target); } + const vue = contributions.find(item => item.id === "vue"); + const irrelevant = new Set((vue?.fileBlockers ?? []).filter(item => !reachable.has(item.path)).map(item => item.reason)); + const outOfSuiteBlockers = irrelevant.size; + if (profile.vueRuntimeIsolationVerified) { + const closure = (starts: string[]) => { + const visited = new Set(starts); const queue = [...starts]; + while (queue.length) for (const target of outgoing.get(queue.pop()!) ?? []) if (!visited.has(target)) { visited.add(target); queue.push(target); } + return visited; + }; + const shared = closure(profile.vueSetupPaths ?? []); + const runtime = (vue?.fileBlockers ?? []).filter(item => item.reason === `Vue ${item.path}: runtime component/directive resolution requires full validation` && reachable.has(item.path)); + // Unknown outgoing runtime edges affect every test reaching this component. + // Run all such isolated test files for EVERY delta, regardless of static impact. + // Shared setup/configuration uncertainty cannot be confined to those files. + if (runtime.length && runtime.every(item => !shared.has(item.path))) { + const targets = new Set(runtime.map(item => item.path)); + profile.vueRuntimeAlwaysRunPaths = profile.testFilePaths.filter(path => [...closure([path])].some(dependency => targets.has(dependency))); + for (const item of runtime) irrelevant.add(item.reason); + } + } + for (let i = adapterBlockers.length - 1; i >= 0; i--) if (irrelevant.has(adapterBlockers[i])) adapterBlockers.splice(i, 1); + if (vue?.performance) Object.assign(vue.performance.counts, { verifiedSuiteRoots: roots.length, reachablePaths: reachable.size, outOfSuiteBlockers, runtimeAlwaysRunTests: profile.vueRuntimeAlwaysRunPaths?.length ?? 0 }); + } + } if (unresolved.some((ref) => ref.importer.endsWith(".vue") || stripImportQuery(ref.specifier).endsWith(".vue"))) { adapterBlockers.push("Unresolved Vue dependencies require full validation"); } + filesParsed = sourceFiles.length; + if (scope && unresolved.length) adapterBlockers.push("Unresolved dependencies in the declared Vue suite require full validation"); + if (scope && edges.some(edge => outsideScope(edge.from) || outsideScope(edge.to))) adapterBlockers.push("Vue asset or macro dependency crosses the declared package boundary"); profile.adapterBlockers = [...adapterBlockers]; - - // Keep adapter-provided test identities visible as well. JS/TS tests are parsed above; their - // imports must not be replaced by disconnected leaf nodes merely because tsconfig excludes them. + markPhase("importExtractionAndResolution"); + + // Nested-package test visibility (2026-08-24, biomejs/biome finding): `internalSourcePaths` above is + // strictly the TS PROGRAM's own file list (createProgram()'s `include`/nested-tsconfig-merged + // fileNames) - so a package whose own tsconfig deliberately excludes its test directory (a real, + // common pattern; confirmed verbatim on biome: `packages/@biomejs/js-api/tsconfig.json` has + // `"exclude": ["./tests", "./dist"], "include": ["./src"]`) NEVER contributes those files to the + // program, so they never became graph nodes and `totalTestsInGraph` stayed 0 even though + // `profile.testFilePaths` (the separate, tsconfig-agnostic glob walk in analyzer.ts's discoverTests()) + // already found them correctly. Source-ROOT discovery itself was already correct (the 2026-08-21 + // zod/trpc fallback already lists `packages`/`crates` as roots for exactly this monorepo shape) - the + // gap was narrower: the graph never incorporated what that walk found. Fix: union in any test file + // discoverTests() found that the TS program's own file list missed, as an ADDITIONAL leaf node + // (isTest true; no import edges - we have no real resolution info for a file the type-checker was + // never asked to see, so dependency-graph traversal through it is honestly absent, not guessed at). + // This does NOT add Rust visibility of any kind - testFilePaths only ever contains files already + // matched by the JS/TS test-file patterns; a `.rs` test is never in it and stays "unknown" as before. for (const testPath of profile.testFilePaths) { if (!internalSourcePaths.has(testPath) && !assetPaths.has(testPath)) internalSourcePaths.add(testPath); } @@ -868,9 +989,12 @@ export async function buildDependencyGraph( }; const durationMs = Number(process.hrtime.bigint() - start) / 1_000_000; + markPhase("graphFinalization"); const performance: GraphPerformanceMetrics = { durationMs, + phasesMs, + adapterMetrics: Object.fromEntries(contributions.filter(item => item.performance).map(item => [item.id, item.performance!])), heapUsedMb: heapDuringBuildMb, heapAfterExtractionMb, filesDiscovered: filesParsed, @@ -902,8 +1026,8 @@ export function classifyRepositoryProject(repoPath: string): { capable: boolean; if (typescript.capable) return typescript; const files = adapterFiles(repoPath); if (files.includes("go.mod")) return { capable: true, reason: "Go module (package-level analysis)" }; - if (files.includes("pom.xml") && files.some((file) => file.endsWith(".java") || file.endsWith(".kt"))) return { capable: true, reason: "Maven reactor (module-level Java analysis)" }; if (files.some((file) => file.endsWith(".vue"))) return { capable: true, reason: "Vue single-file components" }; + if (files.includes("pom.xml") && files.some((file) => file.endsWith(".java") || file.endsWith(".kt"))) return { capable: true, reason: "Maven reactor (module-level Java analysis)" }; return { capable: false, reason: "No TypeScript project, Vue components, root Go module, or Maven reactor found" }; } diff --git a/src/repo/impact.ts b/src/repo/impact.ts index 5628fc4..1448226 100644 --- a/src/repo/impact.ts +++ b/src/repo/impact.ts @@ -1,4 +1,6 @@ import { extname, posix } from "node:path"; +import { isGoDiscoveryIgnoredPath } from "./adapters/go.js"; +import { inVuePackage } from "./vue-scope.js"; import type { ChangedFile, GitDelta } from "../git/types.js"; import type { DependencyGraph, DependencyGraphNode, DependencyGraphResult, EntryPoint, RepositoryProfile } from "./types.js"; import type { ChangedImpact, EntryPointImpact, ImpactEvidence, ImpactEvidencePath, ImpactReason, ImpactResult, ImpactRiskSignal, TestImpact } from "./impact-types.js"; @@ -26,7 +28,7 @@ function isDocumentationFile(filePath: string, layout: RepositoryLayout): boolea function isConfigFile(filePath: string): boolean { const CONFIG_FILE_NAMES = new Set(["package.json","package-lock.json","yarn.lock","pnpm-lock.yaml","bun.lockb","bun.lock","tsconfig.json","tsconfig.base.json","tsconfig.build.json","jsconfig.json"]); const base = posix.basename(filePath); - if (["go.mod", "go.sum", "go.work", "go.work.sum", "pom.xml"].includes(base)) return true; + if (["go.mod", "go.sum", "go.work", "go.work.sum"].includes(base)) return true; if (/^(?:vite|vue|nuxt)\.config\./.test(base)) return true; if (CONFIG_FILE_NAMES.has(base)) return true; if (base.startsWith("next.config")) return true; @@ -242,8 +244,21 @@ export class ImpactAnalyzer { const evidence: ImpactEvidence[] = []; const riskSignals: ImpactRiskSignal[] = []; const fallbackReasons: string[] = [...(graphResult.adapterBlockers ?? [])]; + if (profile.vueScope) { + const scope = profile.vueScope; + if (delta.files.some(file => allChangePaths(file).some(path => !inVuePackage(path, scope.packageRoot)))) fallbackReasons.push("Changes outside the declared Vue package require full validation"); + const setupDependencies = new Set((profile.vueSetupPaths ?? []).flatMap(path => [path, ...graph.transitiveDependenciesOf(path)])); + 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"); + } + if (profile.adapters?.some(adapter => adapter.id === "go") && delta.files.some(file => allChangePaths(file).some(isGoDiscoveryIgnoredPath))) { + fallbackReasons.push("Changes in Go discovery-excluded paths require full validation, including runtime test data"); + } + const excludedGoRoots = profile.goExcludedModuleRoots ?? []; + if (delta.files.some(file => allChangePaths(file).some(path => excludedGoRoots.some(root => path.startsWith(root))))) { + fallbackReasons.push("Changes in a Go module outside the declared root-module scope require full validation"); + } for (const file of delta.files) { - if (allChangePaths(file).some((path) => /(?:^|\/)(?:go\.(?:mod|sum|work)|go\.work\.sum|pom\.xml|(?:vite|vue|nuxt)\.config\.[^/]+)$/.test(path))) { + if (allChangePaths(file).some((path) => /(?:^|\/)(?:diffci\.json|go\.(?:mod|sum|work)|go\.work\.sum|(?:vite|vue|nuxt)\.config\.[^/]+)$/.test(path))) { fallbackReasons.push(`Language/framework configuration changed: ${file.path}`); } } @@ -293,7 +308,7 @@ export class ImpactAnalyzer { this.handleStructuralNextLayout(changedImpacts, profile, affectedEntryPoints, affectedSources, evidence); this.handleAddedEntryPoints(delta, affectedEntryPoints, affectedSources, affectedTests, evidence, fallbackReasons); - this.collectAlwaysRunTests(profile, graph, affectedTests, evidence); + this.collectAlwaysRunTests(profile, graph, affectedTests, evidence, graphResult.profile.vueRuntimeAlwaysRunPaths ?? []); // Changed-test self-selection invariant (2026-08-24): every executable directly-changed test // (added / modified / renamed-destination / copied-destination) MUST be present in the final @@ -632,8 +647,9 @@ export class ImpactAnalyzer { graph: DependencyGraph, affectedTests: Map, evidence: ImpactEvidence[], + graphRuntimeTests: readonly string[], ): void { - const alwaysRunPaths = new Set(); + const alwaysRunPaths = new Set([...(profile.vueTypeTestPaths ?? []), ...(profile.vueRuntimeAlwaysRunPaths ?? []), ...graphRuntimeTests]); const knownTestPaths = new Set(); for (const testLocation of profile.tests) { for (const node of graph.nodes) { diff --git a/src/repo/repo-config.ts b/src/repo/repo-config.ts index 8bf0efa..eb1fbe7 100644 --- a/src/repo/repo-config.ts +++ b/src/repo/repo-config.ts @@ -27,6 +27,13 @@ import { join } from "node:path"; export interface DiffCiRepositoryConfig { /** Globs for tests that must be selected on every analysed change, regardless of reachability. */ alwaysRunTests?: string[]; + /** Explicitly limit Go selection to the root module's `go test ./...` universe. */ + go?: { scope: "root-module" }; + /** One package's default Vitest suite; paths are relative to the repository/package respectively. */ + vue?: { packageRoot: string; testConfig: string }; + /** Match the Maven lifecycle and profiles used by this repository's CI job. */ + maven?: { goal: "test" | "verify"; profiles?: string[] }; + configurationError?: string; } function readJsonFile(path: string): Record | undefined { @@ -49,7 +56,18 @@ function parseConfig(raw: unknown): DiffCiRepositoryConfig { const alwaysRunTests = Array.isArray(record.alwaysRunTests) ? record.alwaysRunTests.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "") : undefined; - return alwaysRunTests && alwaysRunTests.length > 0 ? { alwaysRunTests } : {}; + const go = record.go as Record | undefined; + const vue = record.vue as Record | undefined; + const maven = record.maven as Record | undefined; + 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 === ".."); + const validVue = vue && (vue.packageRoot === "." || safePath(vue.packageRoot)) && safePath(vue.testConfig) && /^vitest\.config\.[cm]?[jt]s$/.test(vue.testConfig); + 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))); + return { + ...(alwaysRunTests && alwaysRunTests.length > 0 ? { alwaysRunTests } : {}), + ...(go?.scope === "root-module" ? { go: { scope: "root-module" as const } } : {}), + ...(validVue ? { vue: { packageRoot: vue.packageRoot as string, testConfig: vue.testConfig as string } } : record.vue !== undefined ? { configurationError: "Invalid Vue package/suite scope" } : {}), + ...(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" } : {}), + }; } /** @@ -60,7 +78,7 @@ export function readRepositoryConfig(repoPath: string, packageJson?: Record = { @@ -316,6 +314,8 @@ function translateExtglobBody(body: string): string { } function globToRegex(pattern: string): RegExp { + // Runner globs are relative to their configured root; a leading ./ is not a directory. + pattern = pattern.replace(/^(?:\.\/)+/, ""); // Extglob bodies contain `*`, `?` and `|` that must not be rewritten by the wildcard rules below, // so they are lifted out behind placeholders first and restored last. const groups: string[] = []; diff --git a/src/repo/types.ts b/src/repo/types.ts index 33b9cac..1ab8366 100644 --- a/src/repo/types.ts +++ b/src/repo/types.ts @@ -46,6 +46,13 @@ export interface RepositoryProfile { goTestEnvironment?: Record; /** Maven/JUnit test file to owning reactor module (repo-relative, "." for root). */ mavenTestModules?: Record; + /** Nested modules excluded by an explicit root-module execution scope. Changes here force full CI. */ + goExcludedModuleRoots?: string[]; + vueScope?: { packageRoot: string; testConfig: string }; + vueSetupPaths?: string[]; + vueTypeTestPaths?: string[]; + vueRuntimeIsolationVerified?: boolean; + vueRuntimeAlwaysRunPaths?: string[]; packageManager: PackageManager; packageJson: { name?: string; @@ -207,6 +214,8 @@ export interface GraphIntegrityReport { export interface GraphPerformanceMetrics { durationMs: number; + phasesMs?: Record; + adapterMetrics?: Record; counts: Record }>; heapUsedMb?: number; heapAfterExtractionMb?: number; filesDiscovered: number; diff --git a/src/repo/vue-scope.ts b/src/repo/vue-scope.ts new file mode 100644 index 0000000..ee3dde0 --- /dev/null +++ b/src/repo/vue-scope.ts @@ -0,0 +1,122 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import ts from "typescript"; +import { analyzeRepository } from "./analyzer.js"; +import type { RepositoryProfile } from "./types.js"; +import { adapterFiles } from "./adapters/index.js"; + +export function inVuePackage(path: string, root: string): boolean { + return root === "." || path === root || path.startsWith(`${root}/`); +} + +/** Scope is an explicit CI declaration, never inferred from a convenient source directory. */ +export function applyVueScope(repoPath: string, profile: RepositoryProfile): string[] { + const scope = profile.diffciConfig?.vue; + if (!scope) return profile.diffciConfig?.configurationError ? [profile.diffciConfig.configurationError] : []; + const packagePath = join(repoPath, scope.packageRoot); + const prefix = (path: string) => scope.packageRoot === "." ? path.replace(/^\.\//, "") : `${scope.packageRoot}/${path.replace(/^\.\//, "")}`; + if (!existsSync(join(packagePath, "package.json")) || !existsSync(join(packagePath, scope.testConfig))) return ["Vue scope requires an existing package.json and default Vitest config"]; + const actual = relative(realpathSync(repoPath), realpathSync(packagePath)); + if (actual === ".." || actual.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(actual)) return ["Vue package scope escapes the repository"]; + const physicallyLocal = (path: string) => { + const physical = relative(realpathSync(packagePath), realpathSync(join(packagePath, path))).replace(/\\/g, "/"); + return physical !== ".." && !physical.startsWith("../") && !isAbsolute(physical); + }; + if (!["package.json", scope.testConfig].every(physicallyLocal)) return ["Vue package manifest or config crosses the physical package boundary"]; + const scoped = analyzeRepository({ repoPath: packagePath }); + const configs = scoped.testRunnerConfigs ?? []; + if (configs.length !== 1 || configs[0].file !== scope.testConfig || configs[0].runner !== "vitest") return ["Vue scope requires exactly one verified default Vitest suite"]; + if (configs[0].declaresTests && !configs[0].authoritative) return ["Vue scoped test patterns could not be fully verified"]; + 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"]; + const config = ts.createSourceFile(scope.testConfig, readFileSync(join(packagePath, scope.testConfig), "utf8"), ts.ScriptTarget.Latest, true); + const blockers: string[] = []; + const setup: string[] = [prefix(scope.testConfig)]; + let typecheckEnabled = false; + let runtimeIsolation = true; + const allowedImports = new Set(["vitest/config", "@vitejs/plugin-vue", "node:path", "node:url", "path", "url"]); + const factories = new Set(); + const vuePlugins = new Set(); + for (const statement of config.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue; + if (statement.moduleSpecifier.text === "@vitejs/plugin-vue" && statement.importClause?.name) vuePlugins.add(statement.importClause.name.text); + if (statement.moduleSpecifier.text === "vitest/config" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings)) { + for (const element of statement.importClause.namedBindings.elements) if ((element.propertyName ?? element.name).text === "defineConfig") factories.add(element.name.text); + } + } + const exported = config.statements.find(ts.isExportAssignment); + let definition = exported?.expression; + if (definition && ts.isCallExpression(definition) && ts.isIdentifier(definition.expression) && factories.has(definition.expression.text) && definition.arguments.length === 1) definition = definition.arguments[0]; + if (!definition || !ts.isObjectLiteralExpression(definition)) blockers.push("Vue scope requires a literal default Vitest configuration"); + function visit(node: ts.Node): void { + if (ts.isSpreadAssignment(node) || ts.isComputedPropertyName(node)) blockers.push("Vue scoped config contains unmodeled configuration composition"); + 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"); + if (ts.isPropertyAssignment(node) && (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name))) { + const name = node.name.text; + if (name === "isolate" && node.initializer.kind !== ts.SyntaxKind.TrueKeyword) runtimeIsolation = false; + if (["runner", "browser", "poolOptions", "poolMatchGlobs", "environmentMatchGlobs"].includes(name)) runtimeIsolation = false; + if (name === "pool" && (!ts.isStringLiteral(node.initializer) || !["threads", "forks"].includes(node.initializer.text))) runtimeIsolation = false; + if (name === "environment" && (!ts.isStringLiteral(node.initializer) || !["node", "jsdom", "happy-dom"].includes(node.initializer.text))) runtimeIsolation = false; + if (name === "typecheck") { + if (!ts.isObjectLiteralExpression(node.initializer)) blockers.push("Vue typecheck configuration must be literal"); + else for (const property of node.initializer.properties) { + if (!ts.isPropertyAssignment(property) || !(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))) { blockers.push("Vue typecheck configuration is not fully modeled"); continue; } + if (property.name.text === "enabled") { + if (property.initializer.kind === ts.SyntaxKind.TrueKeyword) typecheckEnabled = true; + else if (property.initializer.kind !== ts.SyntaxKind.FalseKeyword) blockers.push("Vue typecheck enabled flag must be literal"); + } + if (["include", "exclude", "only"].includes(property.name.text)) blockers.push("Custom Vue type-test discovery requires full validation"); + } + } + 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"); + if (["root", "projects", "workspace", "extends"].includes(name)) blockers.push("Vue scoped Vitest config overrides its package boundary"); + if (["setupFiles", "globalSetup"].includes(name)) { + const values = ts.isArrayLiteralExpression(node.initializer) ? [...node.initializer.elements] : [node.initializer]; + for (const value of values) { + if (!ts.isStringLiteral(value)) { blockers.push("Vue scoped setup paths must be literal package-local files"); continue; } + const path = relative(packagePath, resolve(packagePath, value.text)).replace(/\\/g, "/"); + if (path.startsWith("../") || isAbsolute(path) || !existsSync(join(packagePath, path)) || !physicallyLocal(path)) blockers.push("Vue scoped setup path escapes or is missing from the package"); + else setup.push(prefix(path)); + } + } + } + ts.forEachChild(node, visit); + } + visit(config); + // Only a literal test object can establish the default isolated Vitest contract. + const testDefinitions = definition && ts.isObjectLiteralExpression(definition) ? definition.properties.filter(p => p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === "test") : []; + const testDefinition = testDefinitions.length === 1 ? testDefinitions[0] : undefined; + const literalProperties = (node: ts.Node): boolean => { + if (ts.isObjectLiteralExpression(node) && node.properties.some(p => !ts.isPropertyAssignment(p) || ts.isComputedPropertyName(p.name))) return false; + let valid = true; + ts.forEachChild(node, child => { if (!literalProperties(child)) valid = false; }); + return valid; + }; + profile.vueRuntimeIsolationVerified = runtimeIsolation && blockers.length === 0 && !!testDefinition && ts.isPropertyAssignment(testDefinition) && ts.isObjectLiteralExpression(testDefinition.initializer) && literalProperties(testDefinition.initializer); + // Package discovery supplies the runner universe. Do not let repository-wide docs, + // playground configs or other frameworks expand or replace this declared suite. + profile.vueScope = scope; + profile.vueSetupPaths = setup; + profile.packageJson = scoped.packageJson; + profile.sourceRoots = scoped.sourceRoots.map(root => ({ ...root, path: prefix(root.path) })); + profile.testFilePaths = scoped.testFilePaths.map(prefix); + profile.testPatterns = scoped.testPatterns?.map(prefix); + profile.testExcludePatterns = scoped.testExcludePatterns?.map(prefix); + profile.testAuthoritativePatterns = scoped.testAuthoritativePatterns?.map(prefix); + if (typecheckEnabled) { + // Vitest's typecheck.include default is independent from its runtime test include. + // Keep every type suite, rather than applying runtime reachability to type checking. + profile.vueTypeTestPaths = adapterFiles(packagePath).filter(path => /\.(?:test|spec)-d\.[cm]?[jt]sx?$/.test(path)).map(prefix); + profile.testFilePaths = [...new Set([...profile.testFilePaths, ...profile.vueTypeTestPaths])].sort(); + profile.testPatterns = [...(profile.testPatterns ?? []), ...profile.vueTypeTestPaths]; + profile.testAuthoritativePatterns = [...(profile.testAuthoritativePatterns ?? []), ...profile.vueTypeTestPaths]; + if (!profile.vueTypeTestPaths.length) blockers.push("Enabled Vue type checking has no discovered type suites"); + } + profile.testIgnoreRegexSources = scoped.testIgnoreRegexSources; + profile.testRoots = scope.packageRoot === "." ? undefined : [scope.packageRoot]; + profile.tests = scoped.tests.map(test => ({ ...test, glob: prefix(test.glob) })); + 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) })); + profile.testUniverse = scoped.testUniverse; + profile.entryPoints = scoped.entryPoints.map(entry => ({ ...entry, path: prefix(entry.path) })); + profile.pathAliases = scoped.pathAliases.map(alias => ({ ...alias, substitutions: alias.substitutions.map(prefix) })); + return [...blockers, ...(profile.testFilePaths.length ? [] : ["Declared Vue suite has no discovered tests"])]; +} diff --git a/tests/cache/vue-analysis-cache.test.ts b/tests/cache/vue-analysis-cache.test.ts new file mode 100644 index 0000000..1fe5f0a --- /dev/null +++ b/tests/cache/vue-analysis-cache.test.ts @@ -0,0 +1,51 @@ +import { strict as assert } from "node:assert"; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { buildDependencyGraph } from "../../src/repo/graph.js"; + +test("Vue cache preserves graphs through warm reuse, changed imports/types, configuration and corruption", async () => { + const root = mkdtempSync(join(tmpdir(), "diffci-vue-cache-")); + const repo = join(root, "repo"); const cache = join(root, "cache"); + const put = (path: string, text: string) => { mkdirSync(dirname(join(repo, path)), { recursive: true }); writeFileSync(join(repo, path), text); }; + put("package.json", '{"devDependencies":{"vitest":"1"}}'); + put("tsconfig.json", '{"compilerOptions":{"moduleResolution":"Bundler","module":"ESNext"},"include":["src","tests"]}'); + put("src/types.ts", 'export interface Props { title: string }'); + put("src/value.ts", 'export const value = 1;'); + put("src/Typed.vue", ''); + put("src/Plain.vue", ''); + put("tests/main.test.ts", 'import A from "../src/Typed.vue"; import B from "../src/Plain.vue";'); + const identity = (r: Awaited>) => JSON.stringify({ nodes: r.graph.nodes, edges: r.graph.edges, blockers: r.adapterBlockers, confidence: r.confidence, unresolved: r.unresolved, tests: r.profile.testFilePaths }); + const compare = async (directory = cache) => { + const clean = await buildDependencyGraph({ repoPath: repo }); + const cached = await buildDependencyGraph({ repoPath: repo, vueAnalysisCache: { directory, version: "test" } }); + assert.equal(identity(cached), identity(clean)); + return cached.performance.adapterMetrics?.vue.counts; + }; + try { + assert.equal((await compare())?.cacheMisses, 2); + assert.equal((await compare())?.cacheHits, 2); + put("src/types.ts", 'export interface Props { title: number; active?: boolean }'); + const changed = await compare(); assert.equal(changed?.cacheMisses, 1); assert.equal(changed?.cacheHits, 1); + put("src/Plain.vue", ''); + assert.equal((await compare())?.cacheMisses, 1); + put("src/Typed.vue", ''); + await compare(); await compare(); + put("src/missing.ts", 'export interface Missing { value: string }'); + assert.equal((await compare())?.cacheMisses, 2); + put("package.json", '{"devDependencies":{"vitest":"2"}}'); + assert.equal((await compare())?.cacheMisses, 2); + for (const name of readdirSync(cache)) writeFileSync(join(cache, name), '{"broken":'); + assert.equal((await compare())?.cacheMisses, 2); + rmSync(join(repo, "src/Plain.vue")); await compare(); + const inside = join(repo, "..cache"); await compare(inside); + assert.throws(() => readdirSync(inside)); + if (process.platform !== "win32") { + const alias = join(root, "alias"); symlinkSync(repo, alias); + await compare(join(alias, "cache")); assert.throws(() => readdirSync(join(repo, "cache"))); + } + // A cache I/O failure must preserve the uncached result. + const unwritable = join(root, "file"); writeFileSync(unwritable, "not a directory"); await compare(unwritable); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/repo/language-adapters.test.ts b/tests/repo/language-adapters.test.ts index 4baaf15..fcddc2c 100644 --- a/tests/repo/language-adapters.test.ts +++ b/tests/repo/language-adapters.test.ts @@ -68,6 +68,22 @@ test("Maven multi-module source change reaches downstream module tests", async ( } finally { rmSync(root, { recursive: true, force: true }); } }); +test("Maven plan uses the repository's declared CI lifecycle and profiles", async () => { + const root = fixture({ + "diffci.json": JSON.stringify({ maven: { goal: "verify", profiles: ["run-its"] } }), + "pom.xml": "exampleparent1tools", + "tools/pom.xml": "tools", + "tools/src/main/java/example/Tool.java": "package example; public class Tool {}", + "tools/src/test/java/example/ToolTest.java": "package example; public class ToolTest {}", + }); + try { + const graph = await buildDependencyGraph({ repoPath: root }); + assert.deepEqual(graph.adapterBlockers, []); + const plan = planSelectiveTestCommands(graph.profile, ["tools/src/test/java/example/ToolTest.java"]); + assert.deepEqual(plan.commands[0]?.args, ["-pl", "tools", "-am", "verify", "-P", "run-its"]); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + test("Spring gs-multi-module shape selects application when library changes", async () => { const root = fixture({ diff --git a/tests/repo/vue-scope.test.ts b/tests/repo/vue-scope.test.ts new file mode 100644 index 0000000..f1abfe5 --- /dev/null +++ b/tests/repo/vue-scope.test.ts @@ -0,0 +1,212 @@ +import { strict as assert } from "node:assert"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test } from "node:test"; +import { buildDependencyGraph } from "../../src/repo/graph.js"; +import { ImpactAnalyzer } from "../../src/repo/impact.js"; +import { planSelectiveTestCommands } from "../../src/planner/test-command.js"; +import type { GitDelta } from "../../src/git/types.js"; + +const base = { + "package.json": '{"private":true,"devDependencies":{"nuxt":"1"}}', + "pnpm-lock.yaml": "lockfileVersion: 9", + "diffci.json": JSON.stringify({ vue: { packageRoot: "packages/ui", testConfig: "vitest.config.ts" } }), + "docs/Broken.vue": "", + "nuxt.config.ts": "export default {}", + "packages/ui/package.json": '{"devDependencies":{"vitest":"1","vue":"3"}}', + "packages/ui/vitest.config.ts": 'import {defineConfig} from "vitest/config"; export default defineConfig({test:{include:["tests/**/*.test.ts"],setupFiles:"./setup.ts"}});', + "packages/ui/setup.ts": 'import { shared } from "./shared"; export const setup = shared;', + "packages/ui/shared.ts": "export const shared = 1;", + "packages/ui/src/value.ts": "export const value = 1;", + "packages/ui/src/Child.vue": '', + "packages/ui/tests/child.test.ts": 'import Child from "../src/Child.vue"; export const child = Child;', + "packages/ui/tests/other.test.ts": "export const other = 1;", +}; +function fixture(extra: Record = {}) { + const root = mkdtempSync(join(tmpdir(), "diffci-vue-scope-")); + for (const [path, text] of Object.entries({ ...base, ...extra })) { mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), text); } + return root; +} +function delta(path: string): GitDelta { + return { baseSha: "base", headSha: "head", files: [{ path, changeType: "modified" }], directories: [], summary: { total: 1, added: 0, modified: 1, deleted: 0, renamed: 0, copied: 0, unmerged: 0, unknown: 0 }, analysis: { empty: false, configChanged: false, dependencyManifestChanged: false, lockfileChanged: false, workflowChanged: false, infrastructureChanged: false, databaseChanged: false } }; +} + +test("Vue root-relative include and exclude globs establish the actual scoped test inventory", async () => { + const root = fixture({ "packages/ui/vitest.config.ts": 'import {defineConfig} from "vitest/config"; export default defineConfig({test:{include:["./**/*.test.{ts,js}"],exclude:["./tests/other.test.ts"]}});' }); + try { + const result = await buildDependencyGraph({ repoPath: root }); + assert.deepEqual(result.adapterBlockers, []); + assert.deepEqual(result.profile.testFilePaths, ["packages/ui/tests/child.test.ts"]); + const impact = new ImpactAnalyzer().analyze(delta("packages/ui/src/value.ts"), result, result.profile); + assert.equal(impact.fallbackRequired, false); + assert.deepEqual(impact.affectedTests.map(test => test.path), ["packages/ui/tests/child.test.ts"]); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Vue scoped blockers exclude unreachable examples but retain imported and setup-loaded components", async () => { + const root = fixture({ "packages/ui/src/Unused.story.vue": "" }); + try { + const isolated = await buildDependencyGraph({ repoPath: root }); + assert.deepEqual(isolated.adapterBlockers, []); + assert.equal(isolated.performance.adapterMetrics?.vue.counts.outOfSuiteBlockers, 1); + assert.equal(new ImpactAnalyzer().analyze(delta("packages/ui/src/value.ts"), isolated, isolated.profile).fallbackRequired, false); + writeFileSync(join(root, "packages/ui/tests/other.test.ts"), 'import "../src/Unused.story.vue";'); + const imported = await buildDependencyGraph({ repoPath: root }); + assert.deepEqual(imported.adapterBlockers, []); + assert.deepEqual(imported.profile.vueRuntimeAlwaysRunPaths, ["packages/ui/tests/other.test.ts"]); + writeFileSync(join(root, "packages/ui/tests/other.test.ts"), 'export const other = 1;'); + writeFileSync(join(root, "packages/ui/setup.ts"), 'import "./src/Unused.story.vue";'); + assert.ok((await buildDependencyGraph({ repoPath: root })).adapterBlockers?.some(reason => reason.includes("Unused.story.vue"))); + } finally { rmSync(root, { recursive: true, force: true }); } +}); +test("Vue runtime uncertainty always runs importing tests for unrelated changes", async () => { + const root = fixture({ + "packages/ui/src/Runtime.vue": '', + "packages/ui/tests/runtime.test.ts": 'import Runtime from "../src/Runtime.vue"; export const component = Runtime;', + }); + try { + const result = await buildDependencyGraph({ repoPath: root }); + assert.deepEqual(result.adapterBlockers, []); + assert.deepEqual(result.profile.vueRuntimeAlwaysRunPaths, ["packages/ui/tests/runtime.test.ts"]); + const impact = new ImpactAnalyzer().analyze(delta("packages/ui/src/value.ts"), result, result.profile); + assert.equal(impact.fallbackRequired, false, impact.fallbackReasons.join("; ")); + assert.deepEqual(impact.affectedTests.map(t => t.path).sort(), ["packages/ui/tests/child.test.ts", "packages/ui/tests/runtime.test.ts"]); + assert.ok(impact.affectedTests.find(t => t.path.endsWith("runtime.test.ts"))?.reasons.includes("ALWAYS_RUN_POLICY")); + const rediscovered = { ...result.profile, vueRuntimeAlwaysRunPaths: undefined }; + const retained = new ImpactAnalyzer().analyze(delta("packages/ui/src/value.ts"), result, rediscovered); + assert.ok(retained.affectedTests.some(t => t.path.endsWith("runtime.test.ts")), "graph protections survive a separately discovered caller profile"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("Vue runtime partition refuses disabled or unverified isolation and shared runtime roots", async () => { + for (const options of ['isolate:false', 'isolate:enabled', 'poolOptions:{threads:{isolate:false}}', 'pool:"custom"', 'environment:"custom"', 'browser:{enabled:true}', 'runner:"./runner"']) { + const root = fixture({ + "packages/ui/vitest.config.ts": `export default {test:{include:["tests/**/*.test.ts"],${options}}};`, + "packages/ui/src/Child.vue": '', + }); + try { + const result = await buildDependencyGraph({ repoPath: root }); + assert.ok(result.adapterBlockers?.some(reason => reason.includes("runtime component")), options); + assert.equal(new ImpactAnalyzer().analyze(delta("packages/ui/src/value.ts"), result, result.profile).fallbackRequired, true); + } finally { rmSync(root, { recursive: true, force: true }); } + } + const root = fixture({ + "packages/ui/vitest.config.ts": 'const shared={isolate:false}; export default {test:{include:["tests/**/*.test.ts"]},test:shared};', + "packages/ui/src/Child.vue": '', + }); + try { + const result = await buildDependencyGraph({ repoPath: root }); + assert.ok(result.adapterBlockers?.length, "duplicate test keys cannot establish isolation from an overridden object"); + assert.notEqual(result.profile.vueRuntimeIsolationVerified, true); + } finally { rmSync(root, { recursive: true, force: true }); } +}); +test("Vue scope isolates unrelated docs, pins the runner cwd/config, and guards setup and outside changes", async () => { + const root = fixture(); + try { + const result = await buildDependencyGraph({ repoPath: root }); + assert.deepEqual(result.adapterBlockers, []); + assert.deepEqual(result.profile.testFilePaths, ["packages/ui/tests/child.test.ts", "packages/ui/tests/other.test.ts"]); + assert.ok(!result.graph.nodes.some(node => node.path.startsWith("docs/"))); + const impact = new ImpactAnalyzer().analyze(delta("packages/ui/src/value.ts"), result, result.profile); + assert.equal(impact.fallbackRequired, false, impact.fallbackReasons.join("; ")); + assert.deepEqual(impact.affectedTests.map(test => test.path), ["packages/ui/tests/child.test.ts"]); + const plan = planSelectiveTestCommands(result.profile, impact.affectedTests.map(test => test.path)); + assert.deepEqual(plan.commands, [{ executable: "pnpm", args: ["--dir", "packages/ui", "exec", "vitest", "run", "--config", "vitest.config.ts", "tests/child.test.ts"] }]); + assert.ok(planSelectiveTestCommands(result.profile, ["docs/unknown.test.ts"]).refusalReason); + for (const path of ["docs/Broken.vue", "packages/ui/setup.ts", "packages/ui/shared.ts", "packages/ui/vitest.config.ts", "diffci.json"]) { + assert.equal(new ImpactAnalyzer().analyze(delta(path), result, result.profile).fallbackRequired, true, path); + } + const renamed = delta("packages/ui/src/moved.ts"); renamed.files[0].oldPath = "outside.ts"; renamed.files[0].changeType = "renamed"; + assert.equal(new ImpactAnalyzer().analyze(renamed, result, result.profile).fallbackRequired, true); + } finally { rmSync(root, { recursive: true, force: true }); } +}); +test("Vue scope refuses crossing imports, missing/dynamic suites and invalid declarations", async () => { + for (const extra of [ + { "packages/ui/src/value.ts": 'export { value } from "../../../shared";', "shared.ts": "export const value = 1;" }, + { "packages/ui/src/Child.vue": '', "other.vue": "" }, + { "packages/ui/vitest.config.ts": 'export default {test:{setupFiles:"../../outside.ts"}}', "outside.ts": "export {};" }, + { "packages/ui/vitest.config.ts": 'import auto from "unplugin-auto-import"; export default {plugins:[auto()]};' }, + { "packages/ui/vitest.config.ts": 'export default {root:"../other"};' }, + { "packages/ui/vitest.config.ts": 'export default {test:{include:["../../outside/*.test.ts"]}};' }, + { "packages/ui/vitest.config.ts": 'const shared={}; export default {...shared};' }, + { "packages/ui/src/value.ts": 'export { value } from "../build/generated";', "packages/ui/build/generated.ts": "export const value = (;" }, + { "packages/ui/src/value.ts": 'export { value } from "../build/generated";', "packages/ui/build/generated.ts": 'export {value} from "../../../shared";', "shared.ts": "export const value = 1;" }, + { "packages/ui/src/value.ts": '/// \nexport const value = 1;', "packages/ui/src/global.ts": "declare const globalValue: number;" }, + { "packages/ui/vitest.config.ts": 'export default {test:{typecheck:{enabled:true,include:["types/*.ts"]}}};' }, + { "diffci.json": JSON.stringify({ vue: { packageRoot: "../escape", testConfig: "vitest.config.ts" } }) }, + { "diffci.json": JSON.stringify({ vue: { packageRoot: "missing", testConfig: "vitest.config.ts" } }) }, + ] as Record[]) { + const root = fixture(extra); + try { + const result = await buildDependencyGraph({ repoPath: root }); + assert.ok(result.adapterBlockers?.length, JSON.stringify(extra)); + assert.equal(new ImpactAnalyzer().analyze(delta("packages/ui/src/value.ts"), result, result.profile).fallbackRequired, true); + } finally { rmSync(root, { recursive: true, force: true }); } + } +}); +test("Vue scoped selections retain every configured default type-test file", async () => { + const root = fixture({ + "packages/ui/vitest.config.ts": 'export default {test:{include:["tests/**/*.test.ts"],typecheck:{enabled:true}}};', + "packages/ui/types/public.test-d.ts": 'import { value } from "../src/value"; export type Value = typeof value;', + "packages/ui/types/other.spec-d.ts": 'export type Other = string;', + }); + try { + const result = await buildDependencyGraph({ repoPath: root }); + assert.deepEqual(result.adapterBlockers, []); + const types = ["packages/ui/types/other.spec-d.ts", "packages/ui/types/public.test-d.ts"]; + assert.deepEqual(result.profile.vueTypeTestPaths, types); + assert.ok(types.every(path => result.profile.testFilePaths.includes(path))); + for (const path of ["packages/ui/src/value.ts", "packages/ui/tests/other.test.ts"]) { + const impact = new ImpactAnalyzer().analyze(delta(path), result, result.profile); + assert.equal(impact.fallbackRequired, false, impact.fallbackReasons.join("; ")); + assert.ok(types.every(path => impact.affectedTests.some(test => test.path === path && test.reasons.includes("ALWAYS_RUN_POLICY")))); + const plan = planSelectiveTestCommands(result.profile, impact.affectedTests.map(test => test.path)); + assert.equal(plan.refusalReason, undefined); + assert.ok(types.every(path => plan.commands[0].args.includes(path.replace("packages/ui/", "")))); + } + } finally { rmSync(root, { recursive: true, force: true }); } +}); +test("Vue scoped syntax analysis follows generated implementations and their transitive imports", async () => { + const root = fixture({ + "packages/ui/src/value.ts": 'export { value } from "../build/generated";', + "packages/ui/build/generated.ts": 'export { value } from "../src/leaf";', + "packages/ui/src/leaf.ts": "export const value = 1;", + }); + try { + const result = await buildDependencyGraph({ repoPath: root }); + assert.deepEqual(result.adapterBlockers, []); + assert.ok(result.graph.edges.some(edge => edge.from === "packages/ui/build/generated.ts" && edge.to === "packages/ui/src/leaf.ts")); + const impact = new ImpactAnalyzer().analyze(delta("packages/ui/src/leaf.ts"), result, result.profile); + assert.equal(impact.fallbackRequired, false, impact.fallbackReasons.join("; ")); + assert.deepEqual(impact.affectedTests.map(test => test.path), ["packages/ui/tests/child.test.ts"]); + } finally { rmSync(root, { recursive: true, force: true }); } +}); +test("Vue scope follows workspace symlinks before accepting a dependency as external", async () => { + const root = fixture({ + "packages/ui/tsconfig.json": '{"compilerOptions":{"moduleResolution":"Bundler","preserveSymlinks":true},"include":["src","tests"]}', + "packages/ui/src/value.ts": 'export {value} from "shared";', + "packages/shared/package.json": '{"name":"shared","main":"index.ts"}', + "packages/shared/index.ts": "export const value = 1;", + }); + try { + mkdirSync(join(root, "node_modules"), { recursive: true }); + symlinkSync(join(root, "packages/shared"), join(root, "node_modules/shared"), "junction"); + const result = await buildDependencyGraph({ repoPath: root }); + assert.ok(result.adapterBlockers?.some(reason => reason.includes("boundary"))); + assert.equal(new ImpactAnalyzer().analyze(delta("packages/ui/src/value.ts"), result, result.profile).fallbackRequired, true); + } finally { rmSync(root, { recursive: true, force: true }); } +}); +test("Vue scope accepts physical third-party dependencies in root node_modules", async () => { + const root = fixture({ + "packages/ui/tsconfig.json": '{"compilerOptions":{"moduleResolution":"Bundler"},"include":["src","tests"]}', + "packages/ui/src/value.ts": 'export {value} from "third-party";', + "node_modules/third-party/package.json": '{"name":"third-party","main":"index.ts"}', + "node_modules/third-party/index.ts": "export const value = 1;", + }); + try { + const result = await buildDependencyGraph({ repoPath: root }); + assert.deepEqual(result.adapterBlockers, []); + assert.ok(result.references.some(ref => ref.specifier === "third-party" && ref.resolution === "external-package")); + } finally { rmSync(root, { recursive: true, force: true }); } +});