Skip to content
Open
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
48 changes: 48 additions & 0 deletions packages/app/src/app/linter-main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// CHANGE: CLI entry point for vibecode-linter
// WHY: Provides command-line interface to run the linter
// QUOTE(TZ): "npx @ton-ai-core/vibecode-linter src/"
// REF: issue-1
// SOURCE: https://effect.website/docs/platform/runtime/ "runMain helps you execute a main effect"
// FORMAT THEOREM: forall args: main(args) = linter_output
// PURITY: SHELL
// EFFECT: Effect<void, ConfigError | CommandError, NodeContext>
// INVARIANT: Parses args, loads config, runs linter
// COMPLEXITY: O(n) where n = |commands|
import { NodeContext, NodeRuntime } from "@effect/platform-node"
import { Effect, Layer, pipe } from "effect"

import { CommandExecutorLive } from "../shell/linter/command-executor.js"
import { ConfigLoaderLive } from "../shell/linter/config-loader.js"
import { linterProgram } from "./linter-program.js"

/**
* Parses command line arguments.
*
* @returns Effect with parsed arguments
*
* @pure false - reads process.argv
*/
const parseArgs = Effect.sync(() => {
const args = process.argv.slice(2)
const directory = args[0] ?? "src/"
const configPath = args[1] ?? "linter.config.json"
const cwd = process.cwd()
return { configPath, cwd, directory }
})

/**
* Combined layer for all linter services.
*/
const LinterLive = Layer.merge(CommandExecutorLive, ConfigLoaderLive)

/**
* Main program composition.
*/
const main = pipe(
parseArgs,
Effect.flatMap(({ configPath, cwd, directory }) => linterProgram(configPath, directory, cwd)),
Effect.provide(LinterLive),
Effect.provide(NodeContext.layer)
)

NodeRuntime.runMain(main)
108 changes: 108 additions & 0 deletions packages/app/src/app/linter-program.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// CHANGE: Main linter program composition
// WHY: Compose the linter workflow as a single Effect
// QUOTE(TZ): "Generate message with linting output"
// REF: issue-1
// SOURCE: n/a
// FORMAT THEOREM: forall args: run(args) = lint_result
// PURITY: SHELL
// EFFECT: Effect<void, ConfigError | CommandError, CommandExecutor | ConfigLoader | Console>
// INVARIANT: Steps are executed in order, output is formatted
// COMPLEXITY: O(n) where n = |commands|
import { Console, Effect, pipe } from "effect"

import type { CommandResult, LinterConfig, LintStep } from "../core/linter/index.js"
import { DiagnosticSummary, formatLintStep, generateLintSteps, substituteDirectory } from "../core/linter/index.js"
import type { CommandError } from "../shell/linter/command-executor.js"
import { CommandExecutor } from "../shell/linter/command-executor.js"
import type { ConfigError } from "../shell/linter/config-loader.js"
import { ConfigLoader } from "../shell/linter/config-loader.js"

/**
* Executes a single lint step and returns the result.
*
* @param step - The lint step to execute
* @param cwd - Current working directory
* @returns Effect with optional command result
*
* @pure false - executes commands
* @effect CommandExecutor, Console
*/
const executeStep = (
step: LintStep,
cwd: string
): Effect.Effect<CommandResult | null, CommandError, CommandExecutor> =>
pipe(
Effect.succeed(step),
Effect.tap(() => Console.log(formatLintStep(step))),
Effect.flatMap((s) => {
if (s._tag === "RunningFix" || s._tag === "RunningDiagnostics") {
return pipe(
CommandExecutor,
Effect.flatMap((executor) => executor.execute(s.toolName, substituteDirectory(s.command, cwd), cwd)),
Effect.tap((_result) => {
const completedStep: LintStep = { _tag: "FixCompleted", toolName: s.toolName }
return Console.log(formatLintStep(completedStep))
}),
Effect.map((result) => result as CommandResult | null)
)
}
return Effect.succeed(null)
})
)

/**
* Runs all lint steps and aggregates results.
*
* @param config - Linter configuration
* @param directory - Target directory
* @param cwd - Current working directory
* @returns Effect with diagnostic summary
*
* @pure false - executes commands
* @effect CommandExecutor, Console
*/
const runLintSteps = (
config: LinterConfig,
directory: string,
cwd: string
): Effect.Effect<DiagnosticSummary, CommandError, CommandExecutor> => {
const lintSteps = generateLintSteps(config, directory)
const stepEffects = lintSteps.map((lintStep) => executeStep(lintStep, cwd))

return pipe(
Effect.all(stepEffects, { concurrency: 1 }),
Effect.map((_results) =>
new DiagnosticSummary({
biomeErrors: 0,
eslintErrors: 0,
totalErrors: 0,
totalWarnings: 0,
typescriptErrors: 0
})
)
)
}

/**
* Main linter program.
*
* @param configPath - Path to linter configuration file
* @param directory - Target directory to lint
* @returns Effect that runs the linter
*
* @pure false - IO operations
* @effect ConfigLoader, CommandExecutor, Console
* @invariant Loads config, runs steps, prints summary
*/
export const linterProgram = (
configPath: string,
directory: string,
cwd: string
): Effect.Effect<void, ConfigError | CommandError, ConfigLoader | CommandExecutor> =>
pipe(
ConfigLoader,
Effect.flatMap((loader) => loader.load(configPath)),
Effect.flatMap((config) => runLintSteps(config, directory, cwd)),
Effect.tap((summary) => Console.log(formatLintStep({ _tag: "Summary", summary }))),
Effect.asVoid
)
54 changes: 54 additions & 0 deletions packages/app/src/core/linter/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// CHANGE: Define linter configuration schema
// WHY: Type-safe configuration parsing for vibecode-linter
// QUOTE(TZ): "commands", "priorityLevels" from issue description
// REF: issue-1
// SOURCE: n/a
// FORMAT THEOREM: forall c in Config: parse(c) = valid(c) or error(c)
// PURITY: CORE
// INVARIANT: Configuration schema matches JSON structure from issue
// COMPLEXITY: O(1)
import * as S from "@effect/schema/Schema"

/**
* Command configuration for running a specific linter/tool.
*
* @pure true
* @invariant level >= 0
*/
export const CommandConfigSchema = S.Struct({
commandName: S.NonEmptyString,
command: S.NonEmptyString,
isCommandFix: S.Boolean,
level: S.Int.pipe(S.nonNegative())
})

export type CommandConfig = S.Schema.Type<typeof CommandConfigSchema>

/**
* Priority level configuration for organizing rules.
*
* @pure true
* @invariant level >= 1
* @invariant rules.length > 0
*/
export const PriorityLevelSchema = S.Struct({
level: S.Int.pipe(S.positive()),
name: S.NonEmptyString,
rules: S.NonEmptyArray(S.NonEmptyString)
})

export type PriorityLevel = S.Schema.Type<typeof PriorityLevelSchema>

/**
* Full linter configuration schema.
*
* @pure true
* @invariant commands may be empty array
* @invariant priorityLevels may be empty array
*/
export const LinterConfigSchema = S.Struct({
commands: S.optionalWith(S.Array(CommandConfigSchema), { default: () => [] }),
priorityLevels: S.optionalWith(S.Array(PriorityLevelSchema), { default: () => [] })
})

export type LinterConfig = S.Schema.Type<typeof LinterConfigSchema>
62 changes: 62 additions & 0 deletions packages/app/src/core/linter/formatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// CHANGE: Pure formatter for linting output
// WHY: Separate presentation logic from business logic
// QUOTE(TZ): "Linting directory: src/", "Running ESLint auto-fix on: src/"
// REF: issue-1
// SOURCE: n/a
// FORMAT THEOREM: forall step in LintStep: format(step) = emoji + text
// PURITY: CORE
// INVARIANT: Pure string transformation, no side effects
// COMPLEXITY: O(1)
import { Match } from "effect"

import type { DiagnosticSummary, LintStep } from "./types.js"

/**
* Formats a linting step into human-readable output.
*
* @param step - The linting step to format
* @returns Formatted string with emoji prefix
*
* @pure true
* @invariant Output always starts with an emoji
* @complexity O(1) time / O(1) space
*/
export const formatLintStep = (step: LintStep): string =>
Match.value(step).pipe(
Match.when({ _tag: "LintingDirectory" }, ({ directory }) => `\uD83D\uDCCB Linting directory: ${directory}`),
Match.when(
{ _tag: "RunningFix" },
({ command, directory, toolName }) =>
`\uD83D\uDD27 Running ${toolName} auto-fix on: ${directory}\n \u21B3 Command: ${command}`
),
Match.when({ _tag: "FixCompleted" }, ({ passes, toolName }) =>
passes === undefined
? `\u2705 ${toolName} auto-fix completed`
: `\u2705 ${toolName} auto-fix completed (${passes} passes)`),
Match.when(
{ _tag: "RunningDiagnostics" },
({ command, directory, toolName }) =>
`\uD83E\uDDEA Running ${toolName} diagnostics on: ${directory}\n \u21B3 Command: ${command}`
),
Match.when({ _tag: "FallbackCheck" }, ({ toolName }) =>
`\uD83D\uDD04 ${toolName}: Falling back to individual file checking...`),
Match.when({ _tag: "Summary" }, ({ summary }) =>
formatSummary(summary)),
Match.exhaustive
)

/**
* Formats the diagnostic summary.
*
* @param summary - The diagnostic summary to format
* @returns Formatted summary string
*
* @pure true
* @invariant Output contains total error and warning counts
* @complexity O(1) time / O(1) space
*/
export const formatSummary = (summary: DiagnosticSummary): string => {
const errorBreakdown =
`${summary.typescriptErrors} TypeScript, ${summary.eslintErrors} ESLint, ${summary.biomeErrors} Biome`
return `\n\uD83D\uDCCA Total: ${summary.totalErrors} errors (${errorBreakdown}), ${summary.totalWarnings} warnings.`
}
13 changes: 13 additions & 0 deletions packages/app/src/core/linter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// CHANGE: Export core linter module
// WHY: Centralized exports for the linter core
// QUOTE(TZ): n/a
// REF: issue-1
// SOURCE: n/a
// FORMAT THEOREM: n/a
// PURITY: CORE
// INVARIANT: Re-exports all core linter types and functions
// COMPLEXITY: O(1)
export * from "./config.js"
export * from "./formatter.js"
export * from "./orchestrator.js"
export * from "./types.js"
101 changes: 101 additions & 0 deletions packages/app/src/core/linter/orchestrator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// CHANGE: Linter orchestration logic
// WHY: Pure business logic for coordinating linting steps
// QUOTE(TZ): "commands with level 0 run first as fix commands"
// REF: issue-1
// SOURCE: n/a
// FORMAT THEOREM: forall config: orchestrate(config) = ordered_steps
// PURITY: CORE
// INVARIANT: Commands are sorted by level, fix commands run before diagnostics
// COMPLEXITY: O(n log n) where n = |commands|
import { Array as A, Order } from "effect"

import type { CommandConfig, LinterConfig } from "./config.js"
import type { LintStep } from "./types.js"

/**
* Order for sorting commands by level (ascending).
*
* @pure true
* @invariant Lower levels come first
*/
const commandByLevel: Order.Order<CommandConfig> = Order.mapInput(Order.number, (c) => c.level)

/**
* Separates commands into fix and diagnostic groups.
*
* @param commands - Array of command configurations
* @returns Tuple of [diagnosticCommands, fixCommands]
*
* @pure true
* @invariant isCommandFix = true -> fixCommands, else diagnosticCommands
* @complexity O(n)
*/
export const partitionCommands = (
commands: ReadonlyArray<CommandConfig>
): readonly [ReadonlyArray<CommandConfig>, ReadonlyArray<CommandConfig>] => A.partition(commands, (c) => c.isCommandFix)

/**
* Sorts commands by their level in ascending order.
*
* @param commands - Array of command configurations
* @returns Sorted array by level
*
* @pure true
* @invariant forall i < j: result[i].level <= result[j].level
* @complexity O(n log n)
*/
export const sortByLevel = (commands: ReadonlyArray<CommandConfig>): ReadonlyArray<CommandConfig> =>
A.sort(commands, commandByLevel)

/**
* Generates lint steps from configuration for a given directory.
*
* @param config - Linter configuration
* @param directory - Target directory to lint
* @returns Array of lint steps in execution order
*
* @pure true
* @invariant Steps are ordered: LintingDirectory -> RunningFix* -> RunningDiagnostics*
* @complexity O(n log n) where n = |commands|
*/
export const generateLintSteps = (config: LinterConfig, directory: string): ReadonlyArray<LintStep> => {
const [diagnosticCommands, fixCommands] = partitionCommands(config.commands)
const sortedFixCommands = sortByLevel(fixCommands)
const sortedDiagnosticCommands = sortByLevel(diagnosticCommands)

const steps: Array<LintStep> = [{ _tag: "LintingDirectory", directory }]

for (const cmd of sortedFixCommands) {
steps.push({
_tag: "RunningFix",
command: cmd.command,
directory,
toolName: cmd.commandName
})
}

for (const cmd of sortedDiagnosticCommands) {
steps.push({
_tag: "RunningDiagnostics",
command: cmd.command,
directory,
toolName: cmd.commandName
})
}

return steps
}

/**
* Replaces directory placeholder in command string.
*
* @param command - Command template string
* @param directory - Directory to substitute
* @returns Command with directory substituted
*
* @pure true
* @invariant "${directory}" or the literal directory path is substituted
* @complexity O(n) where n = |command|
*/
export const substituteDirectory = (command: string, directory: string): string =>
command.replaceAll("${directory}", directory).replaceAll("\"src/\"", `"${directory}"`)
Loading