Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@area44/workflows": patch
---

Fix provider capability isolation in multi-language monorepos and support wrapped Vite+ command detection.
16 changes: 8 additions & 8 deletions dist/autofix.mjs

Large diffs are not rendered by default.

120 changes: 110 additions & 10 deletions src/autofix/adapters/javascript/script-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ function splitChainedCommandsDetailed(shellText: string): SplitResult {
return { commands, hasSyntaxError: state.hasSyntaxError };
}

function splitChainedCommands(shellText: string): string[] {
export function splitChainedCommands(shellText: string): string[] {
return splitChainedCommandsDetailed(shellText).commands;
}

Expand Down Expand Up @@ -240,6 +240,7 @@ interface CommandInspection {
tokens: string[];
hasUnsupportedSyntax: boolean;
hasRedirection: boolean;
hasSyntaxError: boolean;
}

function isBackgroundOp(cmd: string, index: number, state: TokenizerState): boolean {
Expand Down Expand Up @@ -343,6 +344,7 @@ function inspectAndTokenizeCommandLine(cmd: string): CommandInspection {
tokens: [],
hasUnsupportedSyntax: false,
hasRedirection: false,
hasSyntaxError: false,
};
const state: TokenizerState = { inSingleQuote: false, inDoubleQuote: false, isEscaped: false };
const ctx: TokenizeScanContext = { inspection, state };
Expand All @@ -368,6 +370,7 @@ function inspectAndTokenizeCommandLine(cmd: string): CommandInspection {

if (state.inSingleQuote || state.inDoubleQuote || state.isEscaped) {
inspection.hasUnsupportedSyntax = true;
inspection.hasSyntaxError = true;
}

if (hasCharsInWord) {
Expand All @@ -377,7 +380,7 @@ function inspectAndTokenizeCommandLine(cmd: string): CommandInspection {
return inspection;
}

function tokenizeCommandLine(cmd: string): string[] {
export function tokenizeCommandLine(cmd: string): string[] {
return inspectAndTokenizeCommandLine(cmd).tokens;
}

Expand Down Expand Up @@ -416,18 +419,33 @@ function matchesPmDelegation(cmdTokens: string[], targetScriptName: string): boo
return false;
}

export function parseCommandInvocations(scriptBody: string): string[][] {
if (!scriptBody) return [];
const commands = splitChainedCommands(scriptBody);
export interface ParsedCommandInvocations {
invocations: string[][];
hasSyntaxError: boolean;
}

export function parseCommandInvocationsDetailed(scriptBody: string): ParsedCommandInvocations {
if (!scriptBody) return { invocations: [], hasSyntaxError: false };
const splitResult = splitChainedCommandsDetailed(scriptBody);
let hasSyntaxError = splitResult.hasSyntaxError;
const invocations: string[][] = [];
for (const cmd of commands) {
const rawTokens = tokenizeCommandLine(cmd);
const cmdTokens = getCommandInvocationTokens(rawTokens);

for (const cmd of splitResult.commands) {
const inspection = inspectAndTokenizeCommandLine(cmd);
if (inspection.hasSyntaxError) {
hasSyntaxError = true;
}
const cmdTokens = getCommandInvocationTokens(inspection.tokens);
if (cmdTokens.length > 0) {
invocations.push(cmdTokens);
}
}
return invocations;

return { invocations, hasSyntaxError };
}

export function parseCommandInvocations(scriptBody: string): string[][] {
return parseCommandInvocationsDetailed(scriptBody).invocations;
}

/**
Expand Down Expand Up @@ -742,13 +760,95 @@ function extractDelegatedTarget(tokens: string[]): string | undefined {
return undefined;
}

function extractShellWrapperScript(tokens: string[]): string | undefined {
export function extractShellWrapperScript(tokens: string[]): string | undefined {
if (tokens.length >= 3 && (tokens[0] === "sh" || tokens[0] === "bash") && tokens[1] === "-c") {
return tokens[2];
}
return undefined;
}

/**
* Unwraps environment wrapper commands like `cross-env` or `cross-env-shell`,
* stripping initial environment variable assignments to retrieve the target command tokens.
* In accordance with cross-env CLI syntax, cross-env accepts environment variable
* setters (`KEY=val`) followed by the command and its arguments.
*/
export function unwrapEnvironmentWrapper(tokens: string[]): string[] {
if (tokens.length === 0) return tokens;
const [exe] = tokens;
if (exe !== "cross-env" && exe !== "cross-env-shell") {
return tokens;
}

let i = 1;
while (i < tokens.length && isEnvAssignment(tokens[i])) {
i++;
}

return tokens.slice(i);
}

/**
* Checks whether an invocation (or nested wrapped invocations) executes the `vp` command.
* Supports direct invocations, environment wrappers (cross-env), shell wrappers (sh -c, bash -c),
* package-manager execution wrappers (npx, bunx, pnpm exec, etc.), and script delegations.
*/
export function hasVitePlusCommand(
scriptBody: string,
scripts: Record<string, string> = {},
activePath: Set<string> = new Set(),
): boolean {
if (!scriptBody) return false;
const { invocations, hasSyntaxError } = parseCommandInvocationsDetailed(scriptBody);
if (hasSyntaxError) {
return false;
}

for (const rawTokens of invocations) {
const tokens = unwrapEnvironmentWrapper(rawTokens);
if (tokens.length === 0) continue;

if (tokens[0] === "vp") {
return true;
}

// Package-manager runner wrappers: npx vp, bunx vp, pnpm dlx vp, yarn dlx vp, npm exec vp, pnpm exec vp
if ((tokens[0] === "npx" || tokens[0] === "bunx") && tokens[1] === "vp") {
return true;
}
if (
(tokens[0] === "npm" || tokens[0] === "pnpm" || tokens[0] === "yarn") &&
(tokens[1] === "exec" || tokens[1] === "dlx") &&
tokens[2] === "vp"
) {
return true;
}

// Shell wrappers: sh -c "...", bash -c "..."
const wrapped = extractShellWrapperScript(tokens);
if (wrapped !== undefined) {
if (hasVitePlusCommand(wrapped, scripts, activePath)) {
return true;
}
}

// Package-manager script delegation
const target = extractDelegatedTarget(tokens);
if (target && !activePath.has(target) && scripts[target]) {
activePath.add(target);
try {
if (hasVitePlusCommand(scripts[target], scripts, activePath)) {
return true;
}
} finally {
activePath.delete(target);
}
}
}

return false;
}

function isKnownWriteCommand(tokens: string[]): boolean {
return (
isPrettierWrite(tokens) ||
Expand Down
13 changes: 9 additions & 4 deletions src/autofix/adapters/javascript/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import type {
} from "../../types.js";

import { hasVitePlusDependency, type JsPackageManifest } from "../../../javascript-env.js";
import { hasVitePlusCommand } from "./script-adapter.js";

function hasVitePlusScript(scripts?: Record<string, string>): boolean {
if (!scripts) return false;
return Object.values(scripts).some((scriptBody) => {
return hasVitePlusCommand(scriptBody, scripts);
});
}

function hasDependency(pkg: JsPackageManifest | undefined, depName: string): boolean {
if (!pkg) return false;
Expand Down Expand Up @@ -47,10 +55,7 @@ export class VitePlusAdapter implements ToolAdapter {
"vite.config.cjs",
]);

if (
hasDep ||
(hasConfig && pkg?.scripts && Object.values(pkg.scripts).some((s) => s.includes("vp ")))
) {
if (hasDep || (hasConfig && hasVitePlusScript(pkg?.scripts))) {
return { detected: true, reason: "Vite+ dependency and/or configuration detected" };
}
return { detected: false, reason: "no Vite+ dependency or configuration" };
Expand Down
65 changes: 51 additions & 14 deletions src/autofix/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { hasPythonSource } from "../python-env.js";
interface CandidateTool {
adapter: ToolAdapter;
reason?: string;
scope: string;
}

function isTaskCapabilityCovered(
Expand Down Expand Up @@ -54,9 +55,21 @@ function filterCandidateTasks(
return { kept, skipped };
}

function getOrCreateScopeCapabilities(
coveredCapabilitiesByScope: Map<string, Set<Capability>>,
scope: string,
): Set<Capability> {
let caps = coveredCapabilitiesByScope.get(scope);
if (!caps) {
caps = new Set<Capability>();
coveredCapabilitiesByScope.set(scope, caps);
}
return caps;
}

async function processCandidateTools(
candidateTools: CandidateTool[],
providerCapabilities: Set<Capability>,
coveredCapabilitiesByScope: Map<string, Set<Capability>>,
context: ProjectContext,
mode: ExecutionMode,
selectedTools: Array<{ id: string; name: string; reason?: string }>,
Expand All @@ -65,13 +78,17 @@ async function processCandidateTools(
): Promise<void> {
for (const candidate of candidateTools) {
const adapter = candidate.adapter;
const scopeCapabilities = getOrCreateScopeCapabilities(
coveredCapabilitiesByScope,
candidate.scope,
);
const plannedTasks = await adapter.plan(context, mode);

if (plannedTasks.length === 0) {
continue;
}

const { kept, skipped } = filterCandidateTasks(plannedTasks, providerCapabilities);
const { kept, skipped } = filterCandidateTasks(plannedTasks, scopeCapabilities);

if (kept.length === 0) {
skippedTools.push({
Expand All @@ -84,10 +101,10 @@ async function processCandidateTools(

selectedTools.push({ id: adapter.id, name: adapter.name, reason: candidate.reason });
for (const task of kept) {
providerCapabilities.add(task.capability);
scopeCapabilities.add(task.capability);
if (task.capabilities) {
for (const cap of task.capabilities) {
providerCapabilities.add(cap);
scopeCapabilities.add(cap);
}
}
}
Expand All @@ -106,19 +123,33 @@ async function processCandidateTools(

async function processStandaloneAdapters(
registry: AutofixRegistry,
coveredCapabilitiesByScope: Map<string, Set<Capability>>,
context: ProjectContext,
mode: ExecutionMode,
selectedTools: Array<{ id: string; name: string; reason?: string }>,
skippedTools: Array<{ id: string; name: string; reason: string }>,
tasks: ExecutionTask[],
): Promise<void> {
const candidateTools: CandidateTool[] = [];
for (const adapter of registry.getStandaloneAdapters()) {
const result = await adapter.detect(context);
const scope = adapter.scope ?? `standalone:${adapter.id}`;
if (result.detected) {
selectedTools.push({ id: adapter.id, name: adapter.name, reason: result.reason });
const toolTasks = await adapter.plan(context, mode);
tasks.push(...toolTasks);
candidateTools.push({ adapter, reason: result.reason, scope });
} else if (result.reason && !result.reason.startsWith("no ")) {
skippedTools.push({ id: adapter.id, name: adapter.name, reason: result.reason });
}
}

await processCandidateTools(
candidateTools,
coveredCapabilitiesByScope,
context,
mode,
selectedTools,
skippedTools,
tasks,
);
}

function deduplicateTasks(tasks: ExecutionTask[]): ExecutionTask[] {
Expand Down Expand Up @@ -161,33 +192,31 @@ export async function planExecution(
const tasks: ExecutionTask[] = [];

const providers = registry.getProviders();
const coveredCapabilities = new Map<string, Set<Capability>>();
const coveredCapabilitiesByScope = new Map<string, Set<Capability>>();

for (const provider of providers) {
const isDetected = await provider.detect(effectiveContext);
if (!isDetected) {
continue;
}

const providerCapabilities = new Set<Capability>();
coveredCapabilities.set(provider.id, providerCapabilities);

const adapters = provider.getAdapters(effectiveContext);
const candidateTools: CandidateTool[] = [];

// Detect tools
for (const adapter of adapters) {
const result = await adapter.detect(effectiveContext);
const scope = adapter.scope ?? provider.id;
if (result.detected) {
candidateTools.push({ adapter, reason: result.reason });
candidateTools.push({ adapter, reason: result.reason, scope });
} else if (result.reason && !result.reason.startsWith("no ")) {
skippedTools.push({ id: adapter.id, name: adapter.name, reason: result.reason });
}
}

await processCandidateTools(
candidateTools,
providerCapabilities,
coveredCapabilitiesByScope,
effectiveContext,
mode,
selectedTools,
Expand All @@ -196,7 +225,15 @@ export async function planExecution(
);
}

await processStandaloneAdapters(registry, effectiveContext, mode, selectedTools, tasks);
await processStandaloneAdapters(
registry,
coveredCapabilitiesByScope,
effectiveContext,
mode,
selectedTools,
skippedTools,
tasks,
);

const deduplicatedTasks = deduplicateTasks(tasks);

Expand Down
8 changes: 8 additions & 0 deletions src/autofix/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export interface ToolAdapter {
id: string;
name: string;
capabilities: readonly Capability[];
/**
* Conflict domain scope for capability deduplication and conflict resolution.
* - For provider adapters, defaults to `provider.id` (e.g. "javascript", "python").
* - For standalone adapters, can explicitly match a provider id (e.g. "javascript")
* or another shared scope name to resolve genuine conflicts within that domain.
* When omitted, defaults to an isolated per-adapter scope (`standalone:${id}`).
*/
scope?: string;

detect(context: ProjectContext): Promise<AdapterDetectionResult> | AdapterDetectionResult;

Expand Down
Loading
Loading