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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* A file the source-text meta-rules should scan: a TypeScript source file
* (`.ts`/`.tsx`/`.mts`/`.cts`) that isn't generated. A generated `*.gen.{ts,mts,cts}`
* (`.ts`/`.tsx`/`.mts`/`.cts`) that isn't generated. A generated `*.gen.{ts,tsx,mts,cts}`
* ships its own blanket eslint-disable banner and `@ts-nocheck` (e.g. TanStack's
* route tree) and is vendored — the model can't author it — so the
* disable/suppression bans must skip it to stay airtight where it matters.
Expand All @@ -10,5 +10,5 @@
* file walk to pre-filter.
*/
export function isScannableSource(path: string): boolean {
return /\.[cm]?tsx?$/u.test(path) && !/\.gen\.[cm]?ts$/u.test(path);
return /\.[cm]?tsx?$/u.test(path) && !/\.gen\.[cm]?tsx?$/u.test(path);
}
2 changes: 1 addition & 1 deletion packages/core/src/validate/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from "./validate.types";
export { validate } from "./validate";
export { validate, fallbackMessage } from "./validate";
export {
parseTsc,
genericErrors,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/validate/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const FALLBACK_CAP = 1200;
* parseable errors (e.g. a render/build failure). Drops eslint's machine JSON
* line — it's never for human eyes — and caps the length so the terminal isn't
* flooded with raw build output. */
function fallbackMessage(output: string): string {
export function fallbackMessage(output: string): string {
const cleaned = output
.split("\n")
.filter((line) => !isEslintJsonLine(line))
Expand Down
64 changes: 64 additions & 0 deletions packages/core/tests/inference.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test";
import { OpenAICompatibleProvider } from "../src/inference";
import { parseResponse, toWire } from "../src/inference/wire";

function okResponse(): Response {
return new Response(
Expand Down Expand Up @@ -420,3 +421,66 @@ test("qwen keeps per-turn thinking control (no latch)", async () => {
expect(bodies[0]?.chat_template_kwargs).toEqual({ enable_thinking: false });
expect(bodies[1]?.chat_template_kwargs).toEqual({ enable_thinking: true });
});

// DeepSeek's thinking mode requires an assistant turn's reasoning_content to be
// CAPTURED from the response and REPLAYED on the next request — drop it and the
// next turn's history is malformed (the provider crashes). This pins the full
// round-trip across the two pure wire helpers: capture (parseResponse) → replay
// (toWire), with the channel kept distinct from content.
test("captures reasoning_content from a response and replays it for DeepSeek only", () => {
const captured = parseResponse({
choices: [
{
message: {
content: "the answer is 42",
reasoning_content: "let me think step by step…",
},
},
],
});

// capture: reasoning lands on its own channel, not mixed into content.
expect(captured.content).toBe("the answer is 42");
expect(captured.reasoning).toBe("let me think step by step…");

// replay: DeepSeek (includeReasoning=true) gets reasoning_content back…
expect(
toWire(
{
role: "assistant",
content: captured.content,
reasoningContent: captured.reasoning,
},
true
)
).toMatchObject({
role: "assistant",
content: "the answer is 42",
reasoning_content: "let me think step by step…",
});

// …every other provider must NOT receive it.
expect(
toWire(
{
role: "assistant",
content: captured.content,
reasoningContent: captured.reasoning,
},
false
).reasoning_content
).toBeUndefined();
});

test("a response without reasoning_content carries no reasoning channel", () => {
const captured = parseResponse({
choices: [{ message: { content: "plain answer" } }],
});

expect(captured.reasoning).toBeUndefined();
// even on DeepSeek, an empty/absent reasoning field is never serialized.
expect(
toWire({ role: "assistant", content: "plain answer" }, true)
.reasoning_content
).toBeUndefined();
});
4 changes: 1 addition & 3 deletions packages/core/tests/meta-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,7 @@ test("isScannableSource: covers .ts/.tsx/.mts/.cts, skips generated variants", a

for (const ext of ["ts", "tsx", "mts", "cts"]) {
expect(isScannableSource(`src/app.${ext}`)).toBe(true);
expect(
isScannableSource(`src/routeTree.gen.${ext === "tsx" ? "ts" : ext}`)
).toBe(false);
expect(isScannableSource(`src/routeTree.gen.${ext}`)).toBe(false);
}

expect(isScannableSource("src/data.json")).toBe(false);
Expand Down
26 changes: 26 additions & 0 deletions packages/core/tests/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
combinedParser,
parserFor,
isEslintJsonLine,
fallbackMessage,
} from "../src/validate";

test("parseTsc extracts file/line/rule per diagnostic", () => {
Expand Down Expand Up @@ -194,3 +195,28 @@ test("combinedParser structures eslint JSON output (the tsc-passes phase)", () =
rule: "@typescript-eslint/no-non-null-assertion",
});
});

test("fallbackMessage drops eslint's machine JSON line", () => {
const eslintJson = `[{"filePath":"/r/a.ts","messages":[]}]`;
const msg = fallbackMessage(`vite build failed: bad import\n${eslintJson}`);

expect(msg).toBe("vite build failed: bad import");
expect(msg).not.toContain('"filePath"');
});

test("fallbackMessage caps a wall of build output and marks the truncation", () => {
const blob = "x".repeat(5000);
const msg = fallbackMessage(blob);

// capped to FALLBACK_CAP (1200) + the truncation marker, not the raw 5000.
expect(msg.length).toBeLessThan(1300);
expect(msg.startsWith("x".repeat(1200))).toBe(true);
expect(msg).toContain("… (output truncated)");
});

test("fallbackMessage degrades to a fixed string when nothing human-readable remains", () => {
const onlyJson = `[{"filePath":"/r/a.ts","messages":[]}]`;

expect(fallbackMessage(onlyJson)).toBe("command exited non-zero");
expect(fallbackMessage(" \n ")).toBe("command exited non-zero");
});
23 changes: 22 additions & 1 deletion packages/core/tests/process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, expect } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runShellCommand } from "../src/lib/fs/process";
import { runShellCommand, runArgvCommand } from "../src/lib/fs/process";

// P2 (review): a timed-out command killed only the `sh -c` wrapper, so a
// `&`-backgrounded grandchild survived and could still mutate the workspace AFTER
Expand Down Expand Up @@ -58,6 +58,27 @@ test("returns promptly even when a leftover child holds the output pipe open", a
}
});

// A missing binary must surface as exit 127 — NEVER a throw into the loop. If
// `Bun.spawn` rejects (ENOENT), the catch must convert it to a tool-error result
// so a model that runs a non-existent command gets feedback, not a crashed turn.
test("a missing binary returns exit 127 without throwing", async () => {
const dir = await mkdtemp(join(tmpdir(), "tsforge-proc-127-"));

try {
const run = await runArgvCommand(
dir,
["tsforge-nonexistent-binary-xyz", "--version"],
{ timeoutMs: 5000 }
);

expect(run.exitCode).toBe(127);
expect(run.timedOut).toBe(false);
expect(run.stderr.length).toBeGreaterThan(0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

test("a quick command still returns its output and does not report a timeout", async () => {
const dir = await mkdtemp(join(tmpdir(), "tsforge-proc-ok-"));

Expand Down