feat(format): add check and fix workflows

This commit is contained in:
2026-05-14 13:18:49 +00:00
parent 6f0928e7c7
commit 278df40ba7
11 changed files with 635 additions and 250 deletions
+13 -1
View File
@@ -1,6 +1,10 @@
import * as plugins from './mod.plugins.js';
import { FormatContext } from './classes.formatcontext.js';
import type { IPlannedChange, ICheckResult } from './interfaces.format.js';
import type {
IPlannedChange,
ICheckResult,
IFormatWarning,
} from './interfaces.format.js';
import { Project } from '../classes.project.js';
import { FormatStats } from './classes.formatstats.js';
@@ -19,6 +23,14 @@ export abstract class BaseFormatter {
abstract analyze(): Promise<IPlannedChange[]>;
abstract applyChange(change: IPlannedChange): Promise<void>;
get runsWithoutChanges(): boolean {
return false;
}
async validate(): Promise<IFormatWarning[]> {
return [];
}
async execute(changes: IPlannedChange[]): Promise<void> {
const startTime = this.stats.moduleStartTime(this.name);
this.stats.startModule(this.name);
+66 -4
View File
@@ -1,7 +1,11 @@
import * as plugins from './mod.plugins.js';
import { FormatContext } from './classes.formatcontext.js';
import { BaseFormatter } from './classes.baseformatter.js';
import type { IFormatPlan, IPlannedChange } from './interfaces.format.js';
import type {
IFormatPlan,
IPlannedChange,
IFormatWarning,
} from './interfaces.format.js';
import { getModuleIcon } from './interfaces.format.js';
import { logger } from '../gitzone.logging.js';
import { DiffReporter } from './classes.diffreporter.js';
@@ -42,15 +46,21 @@ export class FormatPlanner {
break;
}
}
const warnings = await module.validate();
plan.warnings.push(...warnings);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
plan.warnings.push({
level: 'error',
message: `Failed to analyze module ${module.name}: ${error.message}`,
message: `Failed to analyze module ${module.name}: ${errorMessage}`,
module: module.name,
});
}
}
plan.warnings.push(...this.detectConflictingChanges(plan.changes));
plan.summary.totalFiles =
plan.summary.filesAdded +
plan.summary.filesModified +
@@ -65,11 +75,12 @@ export class FormatPlanner {
context: FormatContext,
): Promise<void> {
const startTime = Date.now();
const changesByModule = this.groupChangesByModule(plan.changes);
for (const module of modules) {
const changes = this.plannedChanges.get(module.name) || [];
const changes = changesByModule.get(module.name) || [];
if (changes.length > 0) {
if (changes.length > 0 || module.runsWithoutChanges) {
logger.log('info', `Executing ${module.name} formatter...`);
await module.execute(changes);
}
@@ -138,4 +149,55 @@ export class FormatPlanner {
return '❌';
}
}
private groupChangesByModule(
changes: IPlannedChange[],
): Map<string, IPlannedChange[]> {
const changesByModule = new Map<string, IPlannedChange[]>();
for (const change of changes) {
const moduleChanges = changesByModule.get(change.module) || [];
moduleChanges.push(change);
changesByModule.set(change.module, moduleChanges);
}
return changesByModule;
}
private detectConflictingChanges(
changes: IPlannedChange[],
): IFormatWarning[] {
const warnings: IFormatWarning[] = [];
const changesByPath = new Map<string, IPlannedChange[]>();
for (const change of changes) {
if (!change.path || change.path === '<various files>') {
continue;
}
const pathChanges = changesByPath.get(change.path) || [];
pathChanges.push(change);
changesByPath.set(change.path, pathChanges);
}
for (const [path, pathChanges] of changesByPath) {
const modules = [...new Set(pathChanges.map((change) => change.module))];
if (modules.length < 2) {
continue;
}
const hasDelete = pathChanges.some((change) => change.type === 'delete');
const plannedContents = pathChanges
.map((change) => change.content)
.filter((content): content is string => content !== undefined);
const uniqueContents = new Set(plannedContents);
const level = hasDelete || uniqueContents.size > 1 ? 'warning' : 'info';
warnings.push({
level,
module: 'planner',
message: `Multiple formatters plan changes for ${path}: ${modules.join(', ')}. They will run in formatter order.`,
});
}
return warnings;
}
}
+43 -15
View File
@@ -1,5 +1,5 @@
import { BaseFormatter } from '../classes.baseformatter.js';
import type { IPlannedChange } from '../interfaces.format.js';
import type { IFormatWarning, IPlannedChange } from '../interfaces.format.js';
import * as plugins from '../mod.plugins.js';
import * as paths from '../../paths.js';
import { logger } from '../../gitzone.logging.js';
@@ -11,6 +11,10 @@ export class LicenseFormatter extends BaseFormatter {
return 'license';
}
get runsWithoutChanges(): boolean {
return true;
}
async analyze(): Promise<IPlannedChange[]> {
// License formatter only checks for incompatible licenses
// It does not modify any files, so return empty array
@@ -18,29 +22,34 @@ export class LicenseFormatter extends BaseFormatter {
return [];
}
async validate(): Promise<IFormatWarning[]> {
const result = await this.checkLicenses();
if (!result || result.failingModules.length === 0) {
return [];
}
return [
{
level: 'error',
module: this.name,
message: `License check failed for ${result.failingModules.length} module(s): ${result.failingModules
.map((failedModule) => `${failedModule.name} (${failedModule.license})`)
.join(', ')}`,
},
];
}
async execute(changes: IPlannedChange[]): Promise<void> {
const startTime = this.stats.moduleStartTime(this.name);
this.stats.startModule(this.name);
try {
// Check if node_modules exists
const nodeModulesPath = plugins.path.join(paths.cwd, 'node_modules');
const nodeModulesExists = await plugins.smartfs
.directory(nodeModulesPath)
.exists();
if (!nodeModulesExists) {
const licenseCheckResult = await this.checkLicenses();
if (!licenseCheckResult) {
logger.log('warn', 'No node_modules found. Skipping license check');
return;
}
// Run license check
const licenseChecker = await plugins.smartlegal.createLicenseChecker();
const licenseCheckResult = await licenseChecker.excludeLicenseWithinPath(
paths.cwd,
INCOMPATIBLE_LICENSES,
);
if (licenseCheckResult.failingModules.length === 0) {
logger.log('info', 'License check passed - no incompatible licenses found');
} else {
@@ -59,4 +68,23 @@ export class LicenseFormatter extends BaseFormatter {
async applyChange(change: IPlannedChange): Promise<void> {
// No file changes for license formatter
}
private async checkLicenses(): Promise<{
failingModules: Array<{ name: string; license: string }>;
} | undefined> {
const nodeModulesPath = plugins.path.join(paths.cwd, 'node_modules');
const nodeModulesExists = await plugins.smartfs
.directory(nodeModulesPath)
.exists();
if (!nodeModulesExists) {
return undefined;
}
const licenseChecker = await plugins.smartlegal.createLicenseChecker();
return await licenseChecker.excludeLicenseWithinPath(
paths.cwd,
INCOMPATIBLE_LICENSES,
);
}
}
+54 -66
View File
@@ -56,7 +56,8 @@ export class PrettierFormatter extends BaseFormatter {
);
allFiles.push(...filteredFiles);
} catch (error) {
logVerbose(`Skipping directory ${dir}: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Skipping directory ${dir}: ${errorMessage}`);
}
}
@@ -72,7 +73,8 @@ export class PrettierFormatter extends BaseFormatter {
const rootLevelFiles = rootFiles.filter((f) => !f.includes('/'));
allFiles.push(...rootLevelFiles);
} catch (error) {
logVerbose(`Skipping pattern ${pattern}: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Skipping pattern ${pattern}: ${errorMessage}`);
}
}
@@ -89,20 +91,46 @@ export class PrettierFormatter extends BaseFormatter {
}
} catch (error) {
// Skip files that can't be accessed
logVerbose(`Skipping ${file} - cannot access: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Skipping ${file} - cannot access: ${errorMessage}`);
}
}
const prettier = await import('prettier');
const prettierConfig = await this.getPrettierConfig();
for (const file of validFiles) {
changes.push({
type: 'modify',
path: file,
module: this.name,
description: 'Format with Prettier',
});
try {
const fileExt = plugins.path.extname(file).toLowerCase();
if (!fileExt) {
continue;
}
const content = (await plugins.smartfs
.file(file)
.encoding('utf8')
.read()) as string;
const formatted = await prettier.format(content, {
filepath: file,
...prettierConfig,
});
if (formatted !== content) {
changes.push({
type: 'modify',
path: file,
module: this.name,
description: 'Format with Prettier',
content: formatted,
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Skipping Prettier analysis for ${file}: ${errorMessage}`);
}
}
logger.log('info', `Found ${changes.length} files to format with Prettier`);
logger.log('info', `Found ${changes.length} files needing Prettier`);
return changes;
}
@@ -127,9 +155,10 @@ export class PrettierFormatter extends BaseFormatter {
this.stats.recordFileOperation(this.name, change.type, true);
} catch (error) {
this.stats.recordFileOperation(this.name, change.type, false);
const errorMessage = error instanceof Error ? error.message : String(error);
logger.log(
'error',
`Failed to format ${change.path}: ${error.message}`,
`Failed to format ${change.path}: ${errorMessage}`,
);
// Don't throw - continue with other files
}
@@ -192,28 +221,32 @@ export class PrettierFormatter extends BaseFormatter {
logVerbose(`No formatting changes for ${change.path}`);
}
} catch (prettierError) {
const prettierErrorMessage = prettierError instanceof Error
? prettierError.message
: String(prettierError);
// Check if it's a parser error
if (
prettierError.message &&
prettierError.message.includes('No parser could be inferred')
) {
logVerbose(`Skipping ${change.path} - ${prettierError.message}`);
if (prettierErrorMessage.includes('No parser could be inferred')) {
logVerbose(`Skipping ${change.path} - ${prettierErrorMessage}`);
return; // Skip this file silently
}
throw prettierError;
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
// Log the full error stack for debugging mkdir issues
if (error.message && error.message.includes('mkdir')) {
if (errorMessage.includes('mkdir')) {
logger.log(
'error',
`Failed to format ${change.path}: ${error.message}`,
`Failed to format ${change.path}: ${errorMessage}`,
);
logger.log('error', `Error stack: ${error.stack}`);
if (errorStack) {
logger.log('error', `Error stack: ${errorStack}`);
}
} else {
logger.log(
'error',
`Failed to format ${change.path}: ${error.message}`,
`Failed to format ${change.path}: ${errorMessage}`,
);
}
throw error;
@@ -234,52 +267,7 @@ export class PrettierFormatter extends BaseFormatter {
});
}
/**
* Override check() to compute diffs on-the-fly by running prettier
*/
async check(): Promise<ICheckResult> {
const changes = await this.analyze();
const diffs: ICheckResult['diffs'] = [];
for (const change of changes) {
if (change.type !== 'modify') continue;
try {
// Read current content
const currentContent = (await plugins.smartfs
.file(change.path)
.encoding('utf8')
.read()) as string;
// Skip files without extension (prettier can't infer parser)
const fileExt = plugins.path.extname(change.path).toLowerCase();
if (!fileExt) continue;
// Format with prettier to get what it would produce
const prettier = await import('prettier');
const formatted = await prettier.format(currentContent, {
filepath: change.path,
...(await this.getPrettierConfig()),
});
// Only add to diffs if content differs
if (formatted !== currentContent) {
diffs.push({
path: change.path,
type: 'modify',
before: currentContent,
after: formatted,
});
}
} catch (error) {
// Skip files that can't be processed
logVerbose(`Skipping diff for ${change.path}: ${error.message}`);
}
}
return {
hasDiff: diffs.length > 0,
diffs,
};
return await super.check();
}
}
+289 -8
View File
@@ -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("");
}
+8 -11
View File
@@ -1,3 +1,9 @@
export type IFormatWarning = {
level: 'info' | 'warning' | 'error';
message: string;
module: string;
};
export type IFormatPlan = {
summary: {
totalFiles: number;
@@ -5,17 +11,8 @@ export type IFormatPlan = {
filesModified: number;
filesRemoved: number;
};
changes: Array<{
type: 'create' | 'modify' | 'delete';
path: string;
module: string;
description: string;
}>;
warnings: Array<{
level: 'info' | 'warning' | 'error';
message: string;
module: string;
}>;
changes: IPlannedChange[];
warnings: IFormatWarning[];
};
export type IPlannedChange = {