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
11 changes: 11 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,17 @@ export {
invokeCodingAgentDriver,
type AttemptLogSink,
} from "./miner/coding-agent-invoke.js";
export {
classifyLintGuardPackage,
guardChangedFiles,
guardCodingAgentDriverResult,
type LintGuardCheckResult,
type LintGuardedDriverResult,
type LintGuardOptions,
type LintGuardPackage,
type LintGuardResult,
type LintGuardSpawnFn,
} from "./miner/lint-guard.js";
export {
CODING_AGENT_DRIVER_CONFIG_ENV,
CODING_AGENT_DRIVER_NAMES,
Expand Down
15 changes: 12 additions & 3 deletions packages/gittensory-engine/src/miner/driver-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type CodingAgentExecutionMode,
} from "./coding-agent-mode.js";
import type { CodingAgentDriverResult, CodingAgentDriverTask } from "./coding-agent-driver.js";
import { guardCodingAgentDriverResult, type LintGuardOptions, type LintGuardResult } from "./lint-guard.js";

/** Provider names the factory knows how to resolve today. Concrete CLI/SDK drivers land in #4266/#4267. */
export const CODING_AGENT_DRIVER_NAMES = Object.freeze(["noop"] as const);
Expand Down Expand Up @@ -86,12 +87,19 @@ export type RunCodingAgentAttemptOptions = {
task: CodingAgentDriverTask;
log?: AttemptLogSink | undefined;
driver?: CodingAgentDriver | undefined;
/** When supplied, the driver result is run through the lint guard (#4276) before being returned, so a
* live coding-agent edit that fails its own package's typecheck/node --check never reads as `ok: true`. */
lintGuard?: LintGuardOptions | undefined;
};

/** End-to-end entry: resolve mode from config, pick the driver, invoke under mode gating + attempt log. */
/** End-to-end entry: resolve mode from config, pick the driver, invoke under mode gating + attempt log, then
* (when `lintGuard` is supplied) run the changed files through the lint guard before the caller sees the result. */
export async function runCodingAgentAttempt(
options: RunCodingAgentAttemptOptions,
): Promise<{ mode: CodingAgentExecutionMode; result: CodingAgentDriverResult }> {
): Promise<{
mode: CodingAgentExecutionMode;
result: CodingAgentDriverResult & { lintGuard?: LintGuardResult };
}> {
const mode = resolveCodingAgentModeFromConfig({
env: options.env,
agentPaused: options.agentPaused,
Expand All @@ -103,7 +111,8 @@ export async function runCodingAgentAttempt(
driver: options.driver,
});
const result = await invokeCodingAgentDriver(driver, mode, options.task, options.log);
return { mode, result };
if (!options.lintGuard) return { mode, result };
return { mode, result: await guardCodingAgentDriverResult(result, options.lintGuard) };
}

/** Exported for parity tests — wraps a driver without changing its behavior (identity helper). */
Expand Down
131 changes: 131 additions & 0 deletions packages/gittensory-engine/src/miner/lint-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Lint-guarded edit wrapper for coding-agent drivers (#4276). This repo has no repo-wide ESLint (or other
// linter) at the src/packages/* level -- the only ESLint config is apps/gittensory-ui/eslint.config.js,
// wired up solely through `ui:lint`. The gate everything else runs is `typecheck` (`tsc --noEmit`) plus each
// package/* under `packages/*` having its OWN build-time check: `gittensory-engine` runs its own `tsc -p
// tsconfig.json`; `gittensory-miner`/`gittensory-mcp` ship plain JS with `node --check` per shipped `.js`
// file (their non-`.js` files, like hand-written `.d.ts` declarations, are covered by the root typecheck
// instead, same as `src/`). "Lint-guarded" therefore means: after a coding-agent driver edits files, run the
// EXISTING check appropriate to each changed file's package -- never introduce a new linter.
//
// Implementations here MAY perform real IO (spawn `tsc`/`node`/`npm`), same allowance as `CodingAgentDriver`
// itself (coding-agent-driver.ts) -- the spawn function is injected (mirrors `SpawnFn` in
// `src/selfhost/ai.ts`), so this module stays synchronous-IO-free in tests.
import type { CodingAgentDriverResult } from "./coding-agent-driver.js";

/** Which existing check governs a changed file. `root` covers `src/**` and any non-`.js` file under
* `packages/gittensory-miner`/`packages/gittensory-mcp` (e.g. a hand-written `.d.ts`), since those are
* type-checked by the root `tsc --noEmit`, not `node --check`. */
export type LintGuardPackage = "ui" | "engine" | "miner-js" | "mcp-js" | "root";

const MINER_JS_EXTENSION = /\.(js|mjs|cjs)$/;

/** Classify a changed file path (POSIX or Windows separators) into the package whose existing check governs
* it. Pure path matching -- no filesystem access. */
export function classifyLintGuardPackage(path: string): LintGuardPackage {
const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
if (normalized.startsWith("apps/gittensory-ui/")) return "ui";
if (normalized.startsWith("packages/gittensory-engine/")) return "engine";
if (normalized.startsWith("packages/gittensory-miner/") && MINER_JS_EXTENSION.test(normalized)) return "miner-js";
if (normalized.startsWith("packages/gittensory-mcp/") && MINER_JS_EXTENSION.test(normalized)) return "mcp-js";
return "root";
}

/** Injected process runner -- real IO lives here, not in `guardChangedFiles`, so tests never spawn a real
* subprocess. `ok` is derived from the exit code by the caller of this function, not by this type. */
export type LintGuardSpawnFn = (
cmd: string,
args: readonly string[],
opts: { cwd: string },
) => Promise<{ code: number | null; output: string }>;

export type LintGuardCheckResult = {
package: LintGuardPackage;
file: string;
command: string;
ok: boolean;
output: string;
};

/** Structured result -- never a thrown exception -- so a caller (the self-review loop, #2333) can
* distinguish "the edit doesn't typecheck" (a `checks` entry with `ok: false`) from "the coding agent
* itself failed" (a separate concern entirely, see {@link guardCodingAgentDriverResult}). */
export type LintGuardResult = {
ok: boolean;
checks: readonly LintGuardCheckResult[];
};

export type LintGuardOptions = {
spawn: LintGuardSpawnFn;
/** Repo root the checks run from. Default: `process.cwd()`. */
cwd?: string | undefined;
};

const PACKAGE_COMMAND: Readonly<Record<Exclude<LintGuardPackage, "miner-js" | "mcp-js">, readonly string[]>> = Object.freeze({
root: Object.freeze(["npm", "run", "typecheck"]),
engine: Object.freeze(["npm", "run", "build", "--workspace", "@jsonbored/gittensory-engine"]),
ui: Object.freeze(["npm", "run", "ui:typecheck"]),
});

async function runPackageCheck(
pkg: LintGuardPackage,
files: readonly string[],
spawn: LintGuardSpawnFn,
cwd: string,
): Promise<LintGuardCheckResult[]> {
if (pkg === "miner-js" || pkg === "mcp-js") {
// node --check is inherently per-file, unlike the whole-package tsc/ui:lint commands below.
const results: LintGuardCheckResult[] = [];
for (const file of files) {
const { code, output } = await spawn("node", ["--check", file], { cwd });
results.push({ package: pkg, file, command: `node --check ${file}`, ok: code === 0, output });
}
return results;
}
const command = PACKAGE_COMMAND[pkg];
const { code, output } = await spawn(command[0]!, command.slice(1), { cwd });
return [{ package: pkg, file: files.join(", "), command: command.join(" "), ok: code === 0, output }];
}

/**
* Run the existing check for every package a changed file belongs to. One check per package group, not one
* per file (except `node --check`, which is inherently per-file) -- `tsc`/`ui:typecheck` validate a whole
* package at once, so re-running them per file would be redundant work, not extra coverage.
*/
export async function guardChangedFiles(
changedFiles: readonly string[],
options: LintGuardOptions,
): Promise<LintGuardResult> {
const cwd = options.cwd ?? process.cwd();
const byPackage = new Map<LintGuardPackage, string[]>();
for (const file of changedFiles) {
const pkg = classifyLintGuardPackage(file);
const list = byPackage.get(pkg);
if (list) list.push(file);
else byPackage.set(pkg, [file]);
}

const checks: LintGuardCheckResult[] = [];
for (const [pkg, files] of byPackage) {
checks.push(...(await runPackageCheck(pkg, files, options.spawn, cwd)));
}

return { ok: checks.every((check) => check.ok), checks };
}

export type LintGuardedDriverResult = CodingAgentDriverResult & { lintGuard: LintGuardResult };

/**
* Decorate a `CodingAgentDriver` result with its lint-guard verdict. Skips the guard entirely (an empty,
* passing `lintGuard`) when the driver itself failed or reported no changed files -- there is nothing to
* check, and running checks against an untouched tree would only produce a misleading unrelated result.
*/
export async function guardCodingAgentDriverResult(
result: CodingAgentDriverResult,
options: LintGuardOptions,
): Promise<LintGuardedDriverResult> {
if (!result.ok || result.changedFiles.length === 0) {
return { ...result, lintGuard: { ok: true, checks: [] } };
}
const lintGuard = await guardChangedFiles(result.changedFiles, options);
return { ...result, ok: result.ok && lintGuard.ok, lintGuard };
}
Loading