diff --git a/readme.hints.md b/readme.hints.md index 291ed98..c0fb540 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -193,12 +193,22 @@ gitzone format # Read-only JSON plan gitzone format plan --json +# CI-friendly check, exits non-zero when changes or validator errors remain +gitzone format check + # Dry run to preview changes gitzone format --dry-run +# Limit formatter modules +gitzone format --only prettier,packagejson +gitzone format --skip license + # Non-interactive apply gitzone format --write --yes +# Deterministic format first, opencode for remaining issues +gitzone format fix + # Plan only (no execution) gitzone format --plan-only diff --git a/readme.md b/readme.md index ad7daa7..7b83484 100644 --- a/readme.md +++ b/readme.md @@ -309,15 +309,26 @@ gitzone format # Emit a machine-readable plan gitzone format plan --json +# Fail when formatting changes or validator errors remain +gitzone format check + +# Run a subset of formatters +gitzone format --only prettier,packagejson + # Apply changes gitzone format --write # Apply without prompt gitzone format --write --yes + +# Apply deterministic fixes, then use opencode for remaining issues +gitzone format fix ``` Formatters include cleanup, smartconfig normalization, dependency license checks, package metadata normalization, template updates, `.gitignore`, TypeScript config, Prettier, README existence checks, and configured copy operations. +`gitzone format fix` intentionally lives outside the default format path. Normal format runs stay deterministic; the fix command uses opencode only after deterministic formatters have done what they can. + ## Development Services `gitzone services` manages local Docker-backed services for development projects. diff --git a/ts/gitzone.cli.ts b/ts/gitzone.cli.ts index d6fa53b..3d13972 100644 --- a/ts/gitzone.cli.ts +++ b/ts/gitzone.cli.ts @@ -1,13 +1,107 @@ import * as plugins from "./plugins.js"; import * as paths from "./paths.js"; -import { GitzoneConfig } from "./classes.gitzoneconfig.js"; -import { getRawCliMode } from "./helpers.climode.js"; +import { + getProcessUserArgv, + getRawCliMode, + parseCliArgv, +} from "./helpers.climode.js"; import { commitinfo } from "./00_commitinfo_data.js"; -const gitzoneSmartcli = new plugins.smartcli.Smartcli(); +const runParsedCommand = async (argvArg: any): Promise => { + const command = argvArg._?.[0]; + + switch (command) { + case undefined: + case "help": { + const modStandard = await import("./mod_standard/index.js"); + await modStandard.run(argvArg); + break; + } + case "commit": { + const modCommit = await import("./mod_commit/index.js"); + await modCommit.run(argvArg); + break; + } + case "release": { + const modRelease = await import("./mod_release/index.js"); + await modRelease.run(argvArg); + break; + } + case "deprecate": { + const modDeprecate = await import("./mod_deprecate/index.js"); + await modDeprecate.run(); + break; + } + case "docker": { + const modDocker = await import("./mod_docker/index.js"); + await modDocker.run(argvArg); + break; + } + case "format": { + const modFormat = await import("./mod_format/index.js"); + await modFormat.run({ + ...argvArg, + write: argvArg.write || argvArg.w, + dryRun: argvArg["dry-run"], + yes: argvArg.yes || argvArg.y, + planOnly: argvArg["plan-only"] || argvArg.planOnly, + savePlan: argvArg["save-plan"] || argvArg.savePlan, + fromPlan: argvArg["from-plan"] || argvArg.fromPlan, + detailed: argvArg.detailed, + interactive: argvArg.interactive !== false, + verbose: argvArg.verbose, + diff: argvArg.diff, + }); + break; + } + case "meta": { + const modMeta = await import("./mod_meta/index.js"); + await modMeta.run(argvArg); + break; + } + case "open": { + const modOpen = await import("./mod_open/index.js"); + await modOpen.run(argvArg); + break; + } + case "template": { + const modTemplate = await import("./mod_template/index.js"); + await modTemplate.run(argvArg); + break; + } + case "start": { + const modStart = await import("./mod_start/index.js"); + await modStart.run(argvArg); + break; + } + case "helpers": { + const modHelpers = await import("./mod_helpers/index.js"); + await modHelpers.run(argvArg); + break; + } + case "tools": { + const modTools = await import("./mod_tools/index.js"); + await modTools.run(argvArg); + break; + } + case "config": { + const modConfig = await import("./mod_config/index.js"); + await modConfig.run(argvArg); + break; + } + case "services": { + const modServices = await import("./mod_services/index.js"); + await modServices.run(argvArg); + break; + } + default: { + const modStandard = await import("./mod_standard/index.js"); + await modStandard.run(argvArg); + } + } +}; export let run = async () => { - const done = plugins.smartpromise.defer(); const rawCliMode = await getRawCliMode(); // get packageInfo @@ -34,144 +128,10 @@ export let run = async () => { if (rawCliMode.output === "human") { console.log("---------------------------------------------"); } - gitzoneSmartcli.addVersion(packageVersion); - - // ======> Standard task <====== - - /** - * standard task - */ - gitzoneSmartcli.standardCommand().subscribe(async (argvArg) => { - const modStandard = await import("./mod_standard/index.js"); - await modStandard.run(argvArg); - }); - - gitzoneSmartcli.addCommand("help").subscribe(async (argvArg) => { - const modStandard = await import("./mod_standard/index.js"); - await modStandard.run(argvArg); - }); - - // ======> Specific tasks <====== - - /** - * commit something - */ - gitzoneSmartcli.addCommand("commit").subscribe(async (argvArg) => { - const modCommit = await import("./mod_commit/index.js"); - await modCommit.run(argvArg); - }); - - /** - * create a release from pending changelog entries - */ - gitzoneSmartcli.addCommand("release").subscribe(async (argvArg) => { - const modRelease = await import("./mod_release/index.js"); - await modRelease.run(argvArg); - }); - - /** - * deprecate a package on npm - */ - gitzoneSmartcli.addCommand("deprecate").subscribe(async (argvArg) => { - const modDeprecate = await import("./mod_deprecate/index.js"); - await modDeprecate.run(); - }); - - /** - * docker - */ - gitzoneSmartcli.addCommand("docker").subscribe(async (argvArg) => { - const modDocker = await import("./mod_docker/index.js"); - await modDocker.run(argvArg); - }); - - /** - * Update all files that comply with the gitzone standard - */ - gitzoneSmartcli.addCommand("format").subscribe(async (argvArg) => { - const config = GitzoneConfig.fromCwd(); - const modFormat = await import("./mod_format/index.js"); - - // Handle format with options - // Default is dry-mode, use --write/-w to apply changes - await modFormat.run({ - ...argvArg, - write: argvArg.write || argvArg.w, - dryRun: argvArg["dry-run"], - yes: argvArg.yes, - planOnly: argvArg["plan-only"], - savePlan: argvArg["save-plan"], - fromPlan: argvArg["from-plan"], - detailed: argvArg.detailed, - interactive: argvArg.interactive !== false, - verbose: argvArg.verbose, - diff: argvArg.diff, - }); - }); - - /** - * run meta commands - */ - gitzoneSmartcli.addCommand("meta").subscribe(async (argvArg) => { - const config = GitzoneConfig.fromCwd(); - const modMeta = await import("./mod_meta/index.js"); - modMeta.run(argvArg); - }); - - /** - * open assets - */ - gitzoneSmartcli.addCommand("open").subscribe(async (argvArg) => { - const modOpen = await import("./mod_open/index.js"); - modOpen.run(argvArg); - }); - - /** - * add a readme to a project - */ - gitzoneSmartcli.addCommand("template").subscribe(async (argvArg) => { - const modTemplate = await import("./mod_template/index.js"); - modTemplate.run(argvArg); - }); - - /** - * start working on a project - */ - gitzoneSmartcli.addCommand("start").subscribe(async (argvArg) => { - const modTemplate = await import("./mod_start/index.js"); - modTemplate.run(argvArg); - }); - - gitzoneSmartcli.addCommand("helpers").subscribe(async (argvArg) => { - const modHelpers = await import("./mod_helpers/index.js"); - modHelpers.run(argvArg); - }); - - /** - * manage the global @git.zone toolchain - */ - gitzoneSmartcli.addCommand("tools").subscribe(async (argvArg) => { - const modTools = await import("./mod_tools/index.js"); - await modTools.run(argvArg); - }); - - /** - * manage release configuration - */ - gitzoneSmartcli.addCommand("config").subscribe(async (argvArg) => { - const modConfig = await import("./mod_config/index.js"); - await modConfig.run(argvArg); - }); - - /** - * manage development services (MongoDB, S3/MinIO) - */ - gitzoneSmartcli.addCommand("services").subscribe(async (argvArg) => { - const modServices = await import("./mod_services/index.js"); - await modServices.run(argvArg); - }); - - // start parsing of the cli - gitzoneSmartcli.startParse(); - return await done.promise; + const argvArg = parseCliArgv(getProcessUserArgv()); + if (argvArg.v || argvArg.version) { + console.log(packageVersion); + return; + } + await runParsedCommand(argvArg); }; diff --git a/ts/helpers.climode.ts b/ts/helpers.climode.ts index bd60a02..3a84610 100644 --- a/ts/helpers.climode.ts +++ b/ts/helpers.climode.ts @@ -88,6 +88,41 @@ const parseRawArgv = (argv: string[]): TArgSource => { return parsedArgv; }; +export const parseCliArgv = parseRawArgv; + +export const getProcessUserArgv = (): string[] => { + const rawArgv = process.argv; + const argv0Base = (rawArgv[0] || "").split(/[\\/]/).pop()?.toLowerCase(); + const runtimeNames = new Set([ + "node", + "node.exe", + "nodejs", + "nodejs.exe", + "bun", + "bun.exe", + "deno", + "deno.exe", + "tsx", + "tsx.exe", + "ts-node", + "ts-node.exe", + ]); + + if (!runtimeNames.has(argv0Base || "")) { + return rawArgv.slice(); + } + + const firstUserArg = rawArgv[1] || ""; + const firstUserArgLooksLikeScript = + firstUserArg.includes("/") || + firstUserArg.endsWith(".js") || + firstUserArg.endsWith(".ts") || + firstUserArg.endsWith(".mjs") || + firstUserArg.endsWith(".cjs"); + + return rawArgv.slice(firstUserArgLooksLikeScript ? 2 : 1); +}; + const normalizeOutputMode = (value: unknown): TCliOutputMode | undefined => { if (value === "human" || value === "plain" || value === "json") { return value; @@ -171,7 +206,7 @@ export const getCliMode = async ( export const getRawCliMode = async (): Promise => { const cliConfig = await getCliModeConfig(); - const rawArgv = parseRawArgv(process.argv.slice(2)); + const rawArgv = parseRawArgv(getProcessUserArgv()); return resolveCliMode(rawArgv, cliConfig); }; diff --git a/ts/mod_format/classes.baseformatter.ts b/ts/mod_format/classes.baseformatter.ts index 3d56c7d..8e91b6e 100644 --- a/ts/mod_format/classes.baseformatter.ts +++ b/ts/mod_format/classes.baseformatter.ts @@ -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; abstract applyChange(change: IPlannedChange): Promise; + get runsWithoutChanges(): boolean { + return false; + } + + async validate(): Promise { + return []; + } + async execute(changes: IPlannedChange[]): Promise { const startTime = this.stats.moduleStartTime(this.name); this.stats.startModule(this.name); diff --git a/ts/mod_format/classes.formatplanner.ts b/ts/mod_format/classes.formatplanner.ts index 71a4cb0..67ed021 100644 --- a/ts/mod_format/classes.formatplanner.ts +++ b/ts/mod_format/classes.formatplanner.ts @@ -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 { 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 { + const changesByModule = new Map(); + 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(); + + for (const change of changes) { + if (!change.path || change.path === '') { + 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; + } } diff --git a/ts/mod_format/formatters/license.formatter.ts b/ts/mod_format/formatters/license.formatter.ts index 8d33b82..7dc3203 100644 --- a/ts/mod_format/formatters/license.formatter.ts +++ b/ts/mod_format/formatters/license.formatter.ts @@ -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 { // 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 { + 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 { 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 { // 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, + ); + } } diff --git a/ts/mod_format/formatters/prettier.formatter.ts b/ts/mod_format/formatters/prettier.formatter.ts index 155066f..34f043c 100644 --- a/ts/mod_format/formatters/prettier.formatter.ts +++ b/ts/mod_format/formatters/prettier.formatter.ts @@ -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 { - 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(); } } diff --git a/ts/mod_format/index.ts b/ts/mod_format/index.ts index 5ba48e5..59bdfe2 100644 --- a/ts/mod_format/index.ts +++ b/ts/mod_format/index.ts @@ -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, + mode: ICliMode, +): Promise => { + 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 ", + description: "Run only the comma-separated formatter modules", + }, + { + flag: "--skip ", + 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 Run only comma-separated formatter modules"); + console.log(" --skip 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(""); } diff --git a/ts/mod_format/interfaces.format.ts b/ts/mod_format/interfaces.format.ts index 9e30691..c0a53ed 100644 --- a/ts/mod_format/interfaces.format.ts +++ b/ts/mod_format/interfaces.format.ts @@ -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 = { diff --git a/ts/mod_standard/index.ts b/ts/mod_standard/index.ts index df20e21..f9e5c36 100644 --- a/ts/mod_standard/index.ts +++ b/ts/mod_standard/index.ts @@ -202,6 +202,7 @@ export async function showHelp( console.log(" gitzone commit recommend --json"); console.log(" gitzone release --plan"); console.log(" gitzone format plan --json"); + console.log(" gitzone format check"); console.log(" gitzone services set mongodb,minio"); console.log(" gitzone tools update"); console.log("");