feat(format): add check and fix workflows
This commit is contained in:
+289
-8
@@ -22,6 +22,7 @@ import { TsconfigFormatter } from "./formatters/tsconfig.formatter.js";
|
||||
import { PrettierFormatter } from "./formatters/prettier.formatter.js";
|
||||
import { ReadmeFormatter } from "./formatters/readme.formatter.js";
|
||||
import { CopyFormatter } from "./formatters/copy.formatter.js";
|
||||
import type { ICheckResult, IFormatPlan } from "./interfaces.format.js";
|
||||
|
||||
/**
|
||||
* Rename npmextra.json or smartconfig.json to .smartconfig.json
|
||||
@@ -94,9 +95,39 @@ const getFormatConfig = async () => {
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeModuleList = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => normalizeModuleList(item));
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const getPlanStatus = (plan: IFormatPlan) => {
|
||||
const errorWarnings = plan.warnings.filter(
|
||||
(warning) => warning.level === "error",
|
||||
);
|
||||
const hasChanges = plan.summary.totalFiles > 0;
|
||||
const hasErrors = errorWarnings.length > 0;
|
||||
|
||||
return {
|
||||
ok: !hasChanges && !hasErrors,
|
||||
hasChanges,
|
||||
hasErrors,
|
||||
errorCount: errorWarnings.length,
|
||||
};
|
||||
};
|
||||
|
||||
const createActiveFormatters = async (options: {
|
||||
interactive: boolean;
|
||||
jsonOutput: boolean;
|
||||
only?: string[];
|
||||
skip?: string[];
|
||||
}) => {
|
||||
const project = await Project.fromCwd({ requireProjectType: false });
|
||||
const context = new FormatContext(options);
|
||||
@@ -107,11 +138,19 @@ const createActiveFormatters = async (options: {
|
||||
([, FormatterClass]) => new FormatterClass(context, project),
|
||||
);
|
||||
|
||||
const onlyModules = options.only?.length
|
||||
? options.only
|
||||
: formatConfig.modules.only;
|
||||
const skipModules = [
|
||||
...formatConfig.modules.skip,
|
||||
...(options.skip || []),
|
||||
];
|
||||
|
||||
const activeFormatters = formatters.filter((formatter) => {
|
||||
if (formatConfig.modules.only.length > 0) {
|
||||
return formatConfig.modules.only.includes(formatter.name);
|
||||
if (onlyModules.length > 0) {
|
||||
return onlyModules.includes(formatter.name);
|
||||
}
|
||||
if (formatConfig.modules.skip.includes(formatter.name)) {
|
||||
if (skipModules.includes(formatter.name)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -129,11 +168,15 @@ const buildFormatPlan = async (options: {
|
||||
fromPlan?: string;
|
||||
interactive: boolean;
|
||||
jsonOutput: boolean;
|
||||
only?: string[];
|
||||
skip?: string[];
|
||||
}) => {
|
||||
const { context, planner, formatConfig, activeFormatters } =
|
||||
await createActiveFormatters({
|
||||
interactive: options.interactive,
|
||||
jsonOutput: options.jsonOutput,
|
||||
only: options.only,
|
||||
skip: options.skip,
|
||||
});
|
||||
|
||||
const plan = options.fromPlan
|
||||
@@ -167,6 +210,182 @@ const serializePlan = (plan: any) => {
|
||||
};
|
||||
};
|
||||
|
||||
const buildFormatFixPrompt = (
|
||||
plan: IFormatPlan,
|
||||
extraInstructions: string,
|
||||
): string => {
|
||||
const promptParts = [
|
||||
"Other /c-* commands can be found at ~/.config/opencode/commands/*",
|
||||
"# gitzone format fix",
|
||||
"",
|
||||
`Working directory: ${process.cwd()}`,
|
||||
"",
|
||||
"Repair project formatting so `gitzone format check --json` passes.",
|
||||
"",
|
||||
"Rules:",
|
||||
"- Read `.smartconfig.json`, `package.json`, `tsconfig.json`, and the current format plan before editing.",
|
||||
"- Prefer deterministic gitzone standards, bundled assets, and existing project conventions.",
|
||||
"- Keep changes focused on formatting, metadata normalization, templates, and config consistency.",
|
||||
"- Do not commit, release, install dependencies, or modify unrelated files.",
|
||||
"- Use pnpm commands only if commands are needed.",
|
||||
"- Run `gitzone format --write --yes` after changes.",
|
||||
"- Run `gitzone format check --json` after changes and keep fixing until it passes.",
|
||||
"- Run `git diff --check` after changes to catch whitespace problems.",
|
||||
"",
|
||||
"Current format plan:",
|
||||
JSON.stringify(serializePlan(plan), null, 2),
|
||||
];
|
||||
|
||||
if (extraInstructions) {
|
||||
promptParts.push("", "Additional user instructions:", extraInstructions);
|
||||
}
|
||||
|
||||
return promptParts.join("\n");
|
||||
};
|
||||
|
||||
const handleFormatFix = async (
|
||||
options: Record<string, any>,
|
||||
mode: ICliMode,
|
||||
): Promise<void> => {
|
||||
if (mode.json) {
|
||||
printJson({
|
||||
ok: false,
|
||||
error:
|
||||
"JSON output is not supported for `gitzone format fix`. Use `gitzone format check --json` for machine-readable diagnostics.",
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const extraInstructions = (options._?.slice(2).join(" ") || "").trim();
|
||||
const force = Boolean(options.force);
|
||||
const autoApprove = Boolean(options.yes || mode.yes);
|
||||
const formatConfig = await getFormatConfig();
|
||||
const interactive =
|
||||
options.interactive ?? (mode.interactive && formatConfig.interactive);
|
||||
const only = normalizeModuleList(options.only);
|
||||
const skip = normalizeModuleList(options.skip);
|
||||
|
||||
const buildCurrentPlan = async () => {
|
||||
return await buildFormatPlan({
|
||||
interactive,
|
||||
jsonOutput: false,
|
||||
only,
|
||||
skip,
|
||||
});
|
||||
};
|
||||
|
||||
logger.log("info", "Analyzing project for format fixes...");
|
||||
let { plan } = await buildCurrentPlan();
|
||||
let status = getPlanStatus(plan);
|
||||
|
||||
if (status.ok && !extraInstructions && !force) {
|
||||
logger.log(
|
||||
"success",
|
||||
"Format check found no issues. Use `gitzone format fix --force` to run opencode anyway.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autoApprove) {
|
||||
if (!mode.interactive) {
|
||||
throw new Error(
|
||||
"Format fix requires an interactive terminal or `-y` to run non-interactively.",
|
||||
);
|
||||
}
|
||||
const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation(
|
||||
`Run format fixes? (${plan.summary.totalFiles} planned change(s), ${status.errorCount} error warning(s))`,
|
||||
true,
|
||||
);
|
||||
if (!confirmed) {
|
||||
logger.log("info", "Format fix cancelled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (status.hasChanges) {
|
||||
logger.log("info", "Applying deterministic format changes first...");
|
||||
await run({
|
||||
_: ["format"],
|
||||
write: true,
|
||||
yes: true,
|
||||
interactive: false,
|
||||
verbose: options.verbose,
|
||||
detailed: options.detailed,
|
||||
only: options.only,
|
||||
skip: options.skip,
|
||||
});
|
||||
|
||||
({ plan } = await buildCurrentPlan());
|
||||
status = getPlanStatus(plan);
|
||||
if (status.ok && !extraInstructions && !force) {
|
||||
logger.log("success", "Format fix completed successfully.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const opencodeArgs = [
|
||||
"run",
|
||||
"--title",
|
||||
"gitzone format fix",
|
||||
"--dir",
|
||||
process.cwd(),
|
||||
];
|
||||
if (autoApprove) {
|
||||
opencodeArgs.push("--dangerously-skip-permissions");
|
||||
}
|
||||
opencodeArgs.push(buildFormatFixPrompt(plan, extraInstructions));
|
||||
|
||||
logger.log("info", "Starting opencode format fix...");
|
||||
const smartshellInstance = new plugins.smartshell.Smartshell({
|
||||
executor: "bash",
|
||||
sourceFilePaths: [],
|
||||
});
|
||||
|
||||
let result: plugins.smartshell.IExecResult;
|
||||
try {
|
||||
result = await smartshellInstance.execSpawn("opencode", opencodeArgs, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to run opencode: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.log("error", `opencode exited with code ${result.exitCode}`);
|
||||
process.exitCode = result.exitCode || 1;
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log("info", "Running deterministic format pass after opencode...");
|
||||
await run({
|
||||
_: ["format"],
|
||||
write: true,
|
||||
yes: true,
|
||||
interactive: false,
|
||||
verbose: options.verbose,
|
||||
detailed: options.detailed,
|
||||
only: options.only,
|
||||
skip: options.skip,
|
||||
});
|
||||
|
||||
const { planner: finalPlanner, plan: finalPlan } = await buildCurrentPlan();
|
||||
await finalPlanner.displayPlan(finalPlan, options.detailed);
|
||||
const finalStatus = getPlanStatus(finalPlan);
|
||||
if (finalStatus.ok) {
|
||||
logger.log("success", "Format fix completed successfully.");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(
|
||||
"error",
|
||||
`Format fix left ${finalPlan.summary.totalFiles} planned change(s) and ${finalStatus.errorCount} error warning(s).`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
};
|
||||
|
||||
export let run = async (
|
||||
options: {
|
||||
write?: boolean;
|
||||
@@ -194,8 +413,25 @@ export let run = async (
|
||||
setVerboseMode(true);
|
||||
}
|
||||
|
||||
if (subcommand === "fix") {
|
||||
await handleFormatFix(options, mode);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldWrite = options.write ?? options.dryRun === false;
|
||||
const treatAsPlan = subcommand === "plan";
|
||||
const treatAsCheck = subcommand === "check" || Boolean(options.check);
|
||||
|
||||
if (treatAsCheck && shouldWrite) {
|
||||
const error = "`gitzone format check` is read-only and cannot be combined with --write.";
|
||||
if (mode.json) {
|
||||
printJson({ ok: false, error });
|
||||
} else {
|
||||
logger.log("error", error);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode.json && shouldWrite) {
|
||||
printJson({
|
||||
@@ -212,7 +448,9 @@ export let run = async (
|
||||
const formatConfig = await getFormatConfig();
|
||||
const interactive =
|
||||
options.interactive ?? (mode.interactive && formatConfig.interactive);
|
||||
const autoApprove = options.yes ?? formatConfig.autoApprove;
|
||||
const autoApprove = options.yes ?? (mode.yes || formatConfig.autoApprove);
|
||||
const only = normalizeModuleList(options.only);
|
||||
const skip = normalizeModuleList(options.skip);
|
||||
|
||||
try {
|
||||
const planBuilder = async () => {
|
||||
@@ -220,6 +458,8 @@ export let run = async (
|
||||
fromPlan: options.fromPlan,
|
||||
interactive,
|
||||
jsonOutput: mode.json,
|
||||
only,
|
||||
skip,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -231,7 +471,16 @@ export let run = async (
|
||||
: await planBuilder();
|
||||
|
||||
if (mode.json) {
|
||||
printJson(serializePlan(plan));
|
||||
const serializedPlan = serializePlan(plan);
|
||||
if (treatAsCheck) {
|
||||
const status = getPlanStatus(plan);
|
||||
printJson({ ok: status.ok, ...serializedPlan });
|
||||
if (!status.ok) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
printJson(serializedPlan);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,6 +500,20 @@ export let run = async (
|
||||
return;
|
||||
}
|
||||
|
||||
if (treatAsCheck) {
|
||||
const status = getPlanStatus(plan);
|
||||
if (status.ok) {
|
||||
logger.log("success", "Format check passed");
|
||||
} else {
|
||||
logger.log(
|
||||
"error",
|
||||
`Format check failed: ${plan.summary.totalFiles} planned change(s), ${status.errorCount} error warning(s)`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show diffs if explicitly requested or before interactive write confirmation
|
||||
const showDiffs =
|
||||
options.diff || (shouldWrite && interactive && !autoApprove);
|
||||
@@ -314,7 +577,6 @@ export let run = async (
|
||||
}
|
||||
};
|
||||
|
||||
import type { ICheckResult } from "./interfaces.format.js";
|
||||
export type { ICheckResult };
|
||||
|
||||
/**
|
||||
@@ -363,7 +625,7 @@ export function showHelp(mode?: ICliMode): void {
|
||||
if (mode?.json) {
|
||||
printJson({
|
||||
command: "format",
|
||||
usage: "gitzone format [plan] [options]",
|
||||
usage: "gitzone format [plan|check|fix] [options]",
|
||||
description:
|
||||
"Plans formatting changes by default and applies them only with --write.",
|
||||
flags: [
|
||||
@@ -393,19 +655,33 @@ export function showHelp(mode?: ICliMode): void {
|
||||
flag: "--diff",
|
||||
description: "Show per-file diffs before applying changes",
|
||||
},
|
||||
{
|
||||
flag: "--only <modules>",
|
||||
description: "Run only the comma-separated formatter modules",
|
||||
},
|
||||
{
|
||||
flag: "--skip <modules>",
|
||||
description: "Skip the comma-separated formatter modules",
|
||||
},
|
||||
{
|
||||
flag: "--force",
|
||||
description: "Run `format fix` even when the deterministic plan is clean",
|
||||
},
|
||||
{ flag: "--json", description: "Emit a read-only format plan as JSON" },
|
||||
],
|
||||
examples: [
|
||||
"gitzone format",
|
||||
"gitzone format plan --json",
|
||||
"gitzone format check",
|
||||
"gitzone format --write --yes",
|
||||
"gitzone format fix",
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("Usage: gitzone format [plan] [options]");
|
||||
console.log("Usage: gitzone format [plan|check|fix] [options]");
|
||||
console.log("");
|
||||
console.log(
|
||||
"Plans formatting changes by default and applies them only with --write.",
|
||||
@@ -424,11 +700,16 @@ export function showHelp(mode?: ICliMode): void {
|
||||
console.log(
|
||||
" --diff Show per-file diffs before applying changes",
|
||||
);
|
||||
console.log(" --only <modules> Run only comma-separated formatter modules");
|
||||
console.log(" --skip <modules> Skip comma-separated formatter modules");
|
||||
console.log(" --force Run format fix even when the plan is clean");
|
||||
console.log(" --json Emit a read-only format plan as JSON");
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(" gitzone format");
|
||||
console.log(" gitzone format plan --json");
|
||||
console.log(" gitzone format check");
|
||||
console.log(" gitzone format --write --yes");
|
||||
console.log(" gitzone format fix");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user