diff --git a/packages/opencode/src/altimate/tools/impact-analysis.ts b/packages/opencode/src/altimate/tools/impact-analysis.ts index 6cc71d838..39cc6f96b 100644 --- a/packages/opencode/src/altimate/tools/impact-analysis.ts +++ b/packages/opencode/src/altimate/tools/impact-analysis.ts @@ -152,14 +152,14 @@ export const ImpactAnalysisTool = Tool.define("impact_analysis", { }, }) -interface DownstreamModel { +export interface DownstreamModel { name: string depth: number materialized?: string path: string[] } -function findDownstream( +export function findDownstream( targetName: string, models: Array<{ name: string; depends_on: string[]; materialized?: string }>, ): DownstreamModel[] { @@ -188,7 +188,7 @@ function findDownstream( return results } -function formatImpactReport(data: { +export function formatImpactReport(data: { model: string column?: string changeType: string diff --git a/packages/opencode/src/altimate/tools/training-import.ts b/packages/opencode/src/altimate/tools/training-import.ts index 194a81d14..90c2f1e3e 100644 --- a/packages/opencode/src/altimate/tools/training-import.ts +++ b/packages/opencode/src/altimate/tools/training-import.ts @@ -159,12 +159,12 @@ export const TrainingImportTool = Tool.define("training_import", { }, }) -interface MarkdownSection { +export interface MarkdownSection { name: string content: string } -function parseMarkdownSections(markdown: string): MarkdownSection[] { +export function parseMarkdownSections(markdown: string): MarkdownSection[] { const sections: MarkdownSection[] = [] const lines = markdown.split("\n") let currentH1 = "" @@ -222,11 +222,14 @@ function parseMarkdownSections(markdown: string): MarkdownSection[] { return sections } -function slugify(text: string): string { - return text +export function slugify(text: string): string { + const result = text + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9\s-]/g, "") .replace(/\s+/g, "-") - .replace(/^-+|-+$/g, "") .slice(0, 64) + .replace(/^-+|-+$/g, "") + return result || "untitled" } diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 3295ebd14..86763e07a 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -54,7 +54,9 @@ export namespace Command { const result: string[] = [] const numbered = template.match(/\$\d+/g) if (numbered) { - for (const match of [...new Set(numbered)].sort()) result.push(match) + // altimate_change start — fix lexicographic sort of multi-digit placeholders ($10 before $2) + for (const match of [...new Set(numbered)].sort((a, b) => parseInt(a.slice(1), 10) - parseInt(b.slice(1), 10))) result.push(match) + // altimate_change end } if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS") return result diff --git a/packages/opencode/test/altimate/tools/finops-formatting.test.ts b/packages/opencode/test/altimate/tools/finops-formatting.test.ts index 6598177d2..155fa01e6 100644 --- a/packages/opencode/test/altimate/tools/finops-formatting.test.ts +++ b/packages/opencode/test/altimate/tools/finops-formatting.test.ts @@ -19,12 +19,34 @@ describe("formatBytes: normal cases", () => { }) }) +describe("formatBytes: higher units (TB, PB)", () => { + test("TB boundary", () => { + expect(formatBytes(1024 ** 4)).toBe("1.00 TB") + }) + + test("PB boundary", () => { + expect(formatBytes(1024 ** 5)).toBe("1.00 PB") + }) + + test("values beyond PB stay at PB (no EB unit)", () => { + expect(formatBytes(1024 ** 6)).toBe("1024.00 PB") + }) + + test("multi-PB value", () => { + expect(formatBytes(2 * 1024 ** 5)).toBe("2.00 PB") + }) +}) + describe("formatBytes: edge cases", () => { test("negative bytes displays with sign", () => { expect(formatBytes(-100)).toBe("-100 B") expect(formatBytes(-1536)).toBe("-1.50 KB") }) + test("negative KB", () => { + expect(formatBytes(-1024)).toBe("-1.00 KB") + }) + test("fractional bytes clamps to B unit", () => { expect(formatBytes(0.5)).toBe("1 B") }) diff --git a/packages/opencode/test/altimate/tools/impact-analysis.test.ts b/packages/opencode/test/altimate/tools/impact-analysis.test.ts new file mode 100644 index 000000000..f291e57d7 --- /dev/null +++ b/packages/opencode/test/altimate/tools/impact-analysis.test.ts @@ -0,0 +1,205 @@ +import { describe, test, expect } from "bun:test" +import { findDownstream, formatImpactReport } from "../../../src/altimate/tools/impact-analysis" +import type { DownstreamModel } from "../../../src/altimate/tools/impact-analysis" + +describe("findDownstream: DAG traversal", () => { + test("returns empty for leaf model with no dependents", () => { + const models = [ + { name: "stg_orders", depends_on: ["source.raw_orders"], materialized: "view" }, + { name: "stg_customers", depends_on: ["source.raw_customers"], materialized: "view" }, + ] + const result = findDownstream("stg_orders", models) + expect(result).toHaveLength(0) + }) + + test("finds direct dependents (depth 1)", () => { + const models = [ + { name: "stg_orders", depends_on: ["source.raw_orders"] }, + { name: "fct_orders", depends_on: ["project.stg_orders", "project.stg_customers"] }, + { name: "stg_customers", depends_on: ["source.raw_customers"] }, + ] + const result = findDownstream("stg_orders", models) + expect(result).toHaveLength(1) + expect(result[0].name).toBe("fct_orders") + expect(result[0].depth).toBe(1) + }) + + test("finds transitive dependents across multiple depths", () => { + const models = [ + { name: "stg_orders", depends_on: ["source.raw_orders"] }, + { name: "fct_orders", depends_on: ["project.stg_orders"] }, + { name: "dim_orders", depends_on: ["project.fct_orders"] }, + { name: "report_orders", depends_on: ["project.dim_orders"] }, + ] + const result = findDownstream("stg_orders", models) + expect(result).toHaveLength(3) + expect(result[0]).toMatchObject({ name: "fct_orders", depth: 1 }) + expect(result[1]).toMatchObject({ name: "dim_orders", depth: 2 }) + expect(result[2]).toMatchObject({ name: "report_orders", depth: 3 }) + }) + + test("tracks dependency paths correctly", () => { + const models = [ + { name: "stg_orders", depends_on: [] as string[] }, + { name: "fct_orders", depends_on: ["project.stg_orders"] }, + { name: "report", depends_on: ["project.fct_orders"] }, + ] + const result = findDownstream("stg_orders", models) + expect(result[0].path).toEqual(["stg_orders", "fct_orders"]) + expect(result[1].path).toEqual(["stg_orders", "fct_orders", "report"]) + }) + + test("handles diamond dependency (A\u2192B, A\u2192C, B\u2192D, C\u2192D)", () => { + const models = [ + { name: "A", depends_on: [] as string[] }, + { name: "B", depends_on: ["project.A"] }, + { name: "C", depends_on: ["project.A"] }, + { name: "D", depends_on: ["project.B", "project.C"] }, + ] + const result = findDownstream("A", models) + // D should appear only once (visited set prevents duplicates) + const names = result.map((r) => r.name) + expect(names.filter((n) => n === "D")).toHaveLength(1) + expect(result).toHaveLength(3) // B, C, D + }) + + test("self-referencing model \u2014 behavior documentation only, not a valid dbt graph", () => { + const models = [ + { name: "stg_orders", depends_on: ["project.stg_orders"] }, + ] + // This assertion exists only to document current behavior, not to endorse it. + // Self-referencing dbt models are invalid and cannot compile, so this edge case + // is not reachable in practice. The visited set prevents infinite recursion. + const result = findDownstream("stg_orders", models) + expect(result).toHaveLength(1) + expect(result[0].name).toBe("stg_orders") + }) + + test("parses qualified names (strips prefix before last dot)", () => { + const models = [ + { name: "stg_orders", depends_on: [] as string[] }, + { name: "fct_orders", depends_on: ["my_project.stg_orders", "other_project.stg_customers"] }, + ] + const result = findDownstream("stg_orders", models) + expect(result).toHaveLength(1) + expect(result[0].name).toBe("fct_orders") + }) + + test("preserves materialization metadata", () => { + const models = [ + { name: "stg_orders", depends_on: [] as string[], materialized: "view" }, + { name: "fct_orders", depends_on: ["project.stg_orders"], materialized: "table" }, + { name: "report", depends_on: ["project.fct_orders"], materialized: "incremental" }, + ] + const result = findDownstream("stg_orders", models) + expect(result[0].materialized).toBe("table") + expect(result[1].materialized).toBe("incremental") + }) + + test("model not in graph returns empty", () => { + const models = [ + { name: "stg_orders", depends_on: ["source.raw_orders"] }, + { name: "fct_orders", depends_on: ["project.stg_orders"] }, + ] + const result = findDownstream("nonexistent_model", models) + expect(result).toHaveLength(0) + }) +}) + +describe("formatImpactReport", () => { + test("safe change with zero downstream", () => { + const report = formatImpactReport({ + model: "stg_temp", + changeType: "remove", + direct: [], + transitive: [], + affectedTestCount: 0, + columnImpact: [], + totalModels: 10, + }) + expect(report).toContain("REMOVE stg_temp") + expect(report).toContain("Blast radius: 0/10 models (0.0%)") + expect(report).toContain("No downstream models depend on this. Change is safe to make.") + expect(report).not.toContain("WARNING") + }) + + test("remove with downstream shows BREAKING warning", () => { + const report = formatImpactReport({ + model: "stg_orders", + changeType: "remove", + direct: [{ name: "fct_orders", depth: 1, path: ["stg_orders", "fct_orders"] }], + transitive: [], + affectedTestCount: 0, + columnImpact: [], + totalModels: 20, + }) + expect(report).toContain("WARNING: This is a BREAKING change") + expect(report).toContain("Blast radius: 1/20 models (5.0%)") + expect(report).toContain("Direct Dependents (1)") + expect(report).toContain("Consider deprecation period before removal") + }) + + test("rename shows rename-specific warning and actions", () => { + const report = formatImpactReport({ + model: "stg_orders", + changeType: "rename", + direct: [{ name: "fct_orders", depth: 1, path: ["stg_orders", "fct_orders"] }], + transitive: [], + affectedTestCount: 5, + columnImpact: [], + totalModels: 10, + }) + expect(report).toContain("WARNING: Rename requires updating all downstream references.") + expect(report).toContain("Update all downstream SQL references to new name") + expect(report).toContain("Tests in project: 5") + }) + + test("column-level impact shows affected columns", () => { + const report = formatImpactReport({ + model: "stg_orders", + column: "order_id", + changeType: "retype", + direct: [{ name: "fct_orders", depth: 1, path: ["stg_orders", "fct_orders"] }], + transitive: [], + affectedTestCount: 0, + columnImpact: ["total_amount", "order_count"], + totalModels: 10, + }) + expect(report).toContain("RETYPE stg_orders.order_id") + expect(report).toContain("CAUTION: Type change may cause implicit casts") + expect(report).toContain("Affected Output Columns (2)") + expect(report).toContain("total_amount") + expect(report).toContain("order_count") + }) + + test("percentage calculation with 0 total models does not produce NaN or Infinity", () => { + const report = formatImpactReport({ + model: "stg_orders", + changeType: "add", + direct: [], + transitive: [], + affectedTestCount: 0, + columnImpact: [], + totalModels: 0, + }) + expect(report).not.toContain("NaN") + expect(report).not.toContain("Infinity") + expect(report).toContain("Blast radius: 0/0 models") + }) + + test("transitive dependents show dependency path", () => { + const report = formatImpactReport({ + model: "stg_orders", + changeType: "modify", + direct: [{ name: "fct_orders", depth: 1, path: ["stg_orders", "fct_orders"] }], + transitive: [{ name: "report", depth: 2, materialized: "table", path: ["stg_orders", "fct_orders", "report"] }], + affectedTestCount: 0, + columnImpact: [], + totalModels: 50, + }) + expect(report).toContain("Direct Dependents (1)") + expect(report).toContain("Transitive Dependents (1)") + expect(report).toContain("report [table] (via: stg_orders \u2192 fct_orders \u2192 report)") + expect(report).toContain("Blast radius: 2/50 models (4.0%)") + }) +}) diff --git a/packages/opencode/test/altimate/tools/sql-analyze-tool.test.ts b/packages/opencode/test/altimate/tools/sql-analyze-tool.test.ts new file mode 100644 index 000000000..02994ccee --- /dev/null +++ b/packages/opencode/test/altimate/tools/sql-analyze-tool.test.ts @@ -0,0 +1,193 @@ +/** + * Tests for SqlAnalyzeTool.execute — success flag semantics and output formatting. + * + * The bug fix AI-5975 changed sql_analyze to report success:true when analysis + * completes (even when issues are found). Regression would cause ~4000 false + * "unknown error" telemetry events per day. + */ +import { describe, test, expect, spyOn, afterAll, beforeEach } from "bun:test" +import * as Dispatcher from "../../../src/altimate/native/dispatcher" +import { SqlAnalyzeTool } from "../../../src/altimate/tools/sql-analyze" +import { SessionID, MessageID } from "../../../src/session/schema" + +beforeEach(() => { + process.env.ALTIMATE_TELEMETRY_DISABLED = "true" +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "call_test", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +let dispatcherSpy: ReturnType + +function mockDispatcher(response: any) { + dispatcherSpy?.mockRestore() + dispatcherSpy = spyOn(Dispatcher, "call").mockImplementation(async () => response) +} + +afterAll(() => { + dispatcherSpy?.mockRestore() + delete process.env.ALTIMATE_TELEMETRY_DISABLED +}) + +describe("SqlAnalyzeTool.execute: success semantics", () => { + test("issues found → success:true, no error in metadata", async () => { + mockDispatcher({ + success: true, + issues: [ + { + type: "lint", + severity: "warning", + message: "SELECT * detected", + recommendation: "List columns explicitly", + confidence: "high", + }, + ], + issue_count: 1, + confidence: "high", + confidence_factors: ["lint"], + }) + + const tool = await SqlAnalyzeTool.init() + const result = await tool.execute( + { sql: "SELECT * FROM t", dialect: "snowflake" }, + ctx as any, + ) + + expect(result.metadata.success).toBe(true) + expect(result.metadata.error).toBeUndefined() + expect(result.title).toContain("1 issue") + expect(result.title).not.toContain("PARSE ERROR") + }) + + test("zero issues → success:true, 'No anti-patterns' output", async () => { + mockDispatcher({ + success: true, + issues: [], + issue_count: 0, + confidence: "high", + confidence_factors: [], + }) + + const tool = await SqlAnalyzeTool.init() + const result = await tool.execute( + { sql: "SELECT id FROM t", dialect: "snowflake" }, + ctx as any, + ) + + expect(result.metadata.success).toBe(true) + expect(result.output).toContain("No anti-patterns") + expect(result.title).toContain("0 issues") + }) + + test("parse error → success:false, error in metadata and title", async () => { + mockDispatcher({ + success: false, + issues: [], + issue_count: 0, + confidence: "low", + confidence_factors: [], + error: "syntax error near SELECT", + }) + + const tool = await SqlAnalyzeTool.init() + const result = await tool.execute( + { sql: "SELEC FROM", dialect: "snowflake" }, + ctx as any, + ) + + expect(result.metadata.success).toBe(false) + expect(result.metadata.error).toBe("syntax error near SELECT") + expect(result.title).toContain("PARSE ERROR") + }) + + test("dispatcher throws → catch block returns ERROR title", async () => { + dispatcherSpy?.mockRestore() + dispatcherSpy = spyOn(Dispatcher, "call").mockRejectedValue(new Error("native crash")) + + const tool = await SqlAnalyzeTool.init() + const result = await tool.execute( + { sql: "SELECT 1", dialect: "snowflake" }, + ctx as any, + ) + + expect(result.title).toBe("Analyze: ERROR") + expect(result.metadata.success).toBe(false) + expect(result.metadata.error).toBe("native crash") + expect(result.output).toContain("Failed to analyze SQL: native crash") + }) +}) + +describe("SqlAnalyzeTool.execute: formatAnalysis output", () => { + test("singular issue → '1 issue' not '1 issues'", async () => { + mockDispatcher({ + success: true, + issues: [ + { + type: "lint", + severity: "warning", + message: "test issue", + recommendation: "fix it", + confidence: "high", + }, + ], + issue_count: 1, + confidence: "high", + confidence_factors: ["lint"], + }) + + const tool = await SqlAnalyzeTool.init() + const result = await tool.execute( + { sql: "x", dialect: "snowflake" }, + ctx as any, + ) + + expect(result.output).toContain("Found 1 issue ") + expect(result.output).not.toContain("1 issues") + }) + + test("multiple issues with location, confidence, and factors", async () => { + mockDispatcher({ + success: true, + issues: [ + { + type: "lint", + severity: "warning", + message: "SELECT * used", + recommendation: "List columns", + confidence: "high", + }, + { + type: "safety", + severity: "high", + message: "DROP TABLE detected", + recommendation: "Use caution", + location: "chars 0-5", + confidence: "medium", + }, + ], + issue_count: 2, + confidence: "high", + confidence_factors: ["lint", "safety"], + }) + + const tool = await SqlAnalyzeTool.init() + const result = await tool.execute( + { sql: "x", dialect: "snowflake" }, + ctx as any, + ) + + expect(result.output).toContain("2 issues") + expect(result.output).toContain("[WARNING] lint") + expect(result.output).toContain("[HIGH] safety [medium confidence]") + expect(result.output).toContain("chars 0-5") + expect(result.output).toContain("Note: lint; safety") + }) +}) diff --git a/packages/opencode/test/altimate/tools/training-import.test.ts b/packages/opencode/test/altimate/tools/training-import.test.ts new file mode 100644 index 000000000..2c87cd4b0 --- /dev/null +++ b/packages/opencode/test/altimate/tools/training-import.test.ts @@ -0,0 +1,128 @@ +import { describe, test, expect } from "bun:test" +import { + parseMarkdownSections, + slugify, +} from "../../../src/altimate/tools/training-import" + +describe("slugify", () => { + test("lowercases and replaces spaces with hyphens", () => { + expect(slugify("My SQL Style Guide")).toBe("my-sql-style-guide") + }) + + test("removes special characters", () => { + expect(slugify("Naming Conventions (v2.1)")).toBe("naming-conventions-v21") + }) + + test("collapses multiple spaces", () => { + expect(slugify("Use consistent naming")).toBe("use-consistent-naming") + }) + + test("strips leading and trailing hyphens from realistic input", () => { + // Parentheses-only prefix becomes hyphens that get trimmed + expect(slugify("(Naming)")).toBe("naming") + expect(slugify("---leading---")).toBe("leading") + }) + + test("truncates to 64 characters", () => { + const long = "a".repeat(100) + expect(slugify(long).length).toBe(64) + }) + + test("handles empty string with fallback", () => { + expect(slugify("")).toBe("untitled") + }) + + test("handles string with only special chars with fallback", () => { + expect(slugify("!@#$%")).toBe("untitled") + }) + + test("handles unicode characters (normalizes accents via NFKD)", () => { + expect(slugify("caf\u00e9 rules")).toBe("cafe-rules") + expect(slugify("na\u00efve approach")).toBe("naive-approach") + }) +}) + +describe("parseMarkdownSections", () => { + test("parses simple H2 sections", () => { + const md = `## Naming Convention\nUse snake_case for all columns.\n\n## Type Rules\nAlways use NUMERIC(18,2) for amounts.\n` + const sections = parseMarkdownSections(md) + expect(sections).toHaveLength(2) + expect(sections[0].name).toBe("naming-convention") + expect(sections[0].content).toContain("Use snake_case") + expect(sections[1].name).toBe("type-rules") + expect(sections[1].content).toContain("NUMERIC(18,2)") + }) + + test("H1 context is prepended to H2 sections", () => { + const md = `# SQL Style Guide\n\n## Column Naming\nUse lowercase with underscores.\n\n## Table Naming\nUse plural nouns.\n` + const sections = parseMarkdownSections(md) + expect(sections).toHaveLength(2) + expect(sections[0].content).toContain("Context: SQL Style Guide") + expect(sections[0].content).toContain("Use lowercase with underscores.") + expect(sections[1].content).toContain("Context: SQL Style Guide") + }) + + test("H1 context updates when a new H1 appears", () => { + const md = `# Part 1\n\n## Rule A\nContent A.\n\n# Part 2\n\n## Rule B\nContent B.\n` + const sections = parseMarkdownSections(md) + expect(sections).toHaveLength(2) + expect(sections[0].content).toContain("Context: Part 1") + expect(sections[1].content).toContain("Context: Part 2") + }) + + test("returns empty for markdown with no H2 headings", () => { + const md = `# Just a Title\n\nSome paragraph text without any H2 sections.\n\n### H3 heading (not H2)\nMore text.\n` + const sections = parseMarkdownSections(md) + expect(sections).toHaveLength(0) + }) + + test("returns empty for empty string", () => { + expect(parseMarkdownSections("")).toHaveLength(0) + }) + + test("skips H2 sections with empty content", () => { + const md = `## Empty Section\n## Non-Empty Section\nSome content here.\n` + const sections = parseMarkdownSections(md) + // "Empty Section" has no content lines before next H2 + expect(sections).toHaveLength(1) + expect(sections[0].name).toBe("non-empty-section") + }) + + test("H3 lines are included as content within H2 section", () => { + const md = `## Main Rule\n\n### Sub-rule A\nDetails about A.\n\n### Sub-rule B\nDetails about B.\n` + const sections = parseMarkdownSections(md) + expect(sections).toHaveLength(1) + expect(sections[0].content).toContain("### Sub-rule A") + expect(sections[0].content).toContain("Details about A.") + expect(sections[0].content).toContain("### Sub-rule B") + }) + + test("last section is captured", () => { + const md = `## Only Section\nContent of the only section.` + const sections = parseMarkdownSections(md) + expect(sections).toHaveLength(1) + expect(sections[0].content).toBe("Content of the only section.") + }) + + test("H2 names are slugified", () => { + const md = `## My Complex (Section) Name!\nContent here.\n\n## v2.1 Naming\nVersioned heading.\n` + const sections = parseMarkdownSections(md) + expect(sections[0].name).toBe("my-complex-section-name") + // Dots in version headings are stripped: "v2.1" \u2192 "v21" (not "v2-1") + expect(sections[1].name).toBe("v21-naming") + }) + + test("content is trimmed", () => { + const md = `## Padded Section\n\n Content with leading whitespace preserved per-line.\n\n` + const sections = parseMarkdownSections(md) + // The joined content should be trimmed (no leading/trailing blank lines) + expect(sections[0].content).not.toMatch(/^\n/) + expect(sections[0].content).not.toMatch(/\n$/) + }) + + test("multiple H1s without H2s produce no sections", () => { + const md = `# Header 1\nIntro text.\n\n# Header 2\nMore intro text.\n` + const sections = parseMarkdownSections(md) + expect(sections).toHaveLength(0) + }) +}) diff --git a/packages/opencode/test/cli/error-format.test.ts b/packages/opencode/test/cli/error-format.test.ts new file mode 100644 index 000000000..d7c1b250e --- /dev/null +++ b/packages/opencode/test/cli/error-format.test.ts @@ -0,0 +1,157 @@ +import { describe, test, expect } from "bun:test" +import { FormatError, FormatUnknownError } from "../../src/cli/error" + +describe("FormatError: known error types", () => { + test("MCP.Failed returns helpful message", () => { + const err = { name: "MCPFailed", data: { name: "my-server" } } + const result = FormatError(err) + expect(result).toContain("my-server") + expect(result).toContain("failed") + }) + + test("Provider.ModelNotFoundError with suggestions", () => { + const err = { + name: "ProviderModelNotFoundError", + data: { providerID: "openai", modelID: "gpt-5", suggestions: ["gpt-4", "gpt-4o"] }, + } + const result = FormatError(err) + expect(result).toContain("gpt-5") + expect(result).toContain("Did you mean") + expect(result).toContain("gpt-4") + }) + + test("Provider.ModelNotFoundError without suggestions", () => { + const err = { + name: "ProviderModelNotFoundError", + data: { providerID: "openai", modelID: "gpt-5", suggestions: [] }, + } + const result = FormatError(err) + expect(result).toContain("gpt-5") + expect(result).not.toContain("Did you mean") + }) + + test("Provider.InitError returns provider name", () => { + const err = { name: "ProviderInitError", data: { providerID: "anthropic" } } + const result = FormatError(err) + expect(result).toContain("anthropic") + }) + + test("Config.JsonError with message", () => { + const err = { name: "ConfigJsonError", data: { path: "/home/user/.config/altimate.json", message: "Unexpected token" } } + const result = FormatError(err) + expect(result).toContain("altimate.json") + expect(result).toContain("Unexpected token") + }) + + test("Config.JsonError without message", () => { + const err = { name: "ConfigJsonError", data: { path: "/path/to/config.json" } } + const result = FormatError(err) + expect(result).toContain("config.json") + expect(result).toContain("not valid JSON") + }) + + test("Config.ConfigDirectoryTypoError", () => { + const err = { + name: "ConfigDirectoryTypoError", + data: { dir: ".openCode", path: "/project/.openCode", suggestion: ".opencode" }, + } + const result = FormatError(err) + expect(result).toContain(".openCode") + expect(result).toContain(".opencode") + expect(result).toContain("typo") + }) + + test("ConfigMarkdown.FrontmatterError", () => { + const err = { + name: "ConfigFrontmatterError", + data: { path: "CLAUDE.md", message: "CLAUDE.md: Failed to parse YAML frontmatter: invalid key" }, + } + const result = FormatError(err) + expect(result).toContain("Failed to parse") + }) + + test("Config.InvalidError with issues", () => { + const err = { + name: "ConfigInvalidError", + data: { + path: "provider.model", + message: "Invalid model", + issues: [{ message: "must be string", path: ["provider", "model"] }], + }, + } + const result = FormatError(err) + expect(result).toContain("provider.model") + expect(result).toContain("must be string") + }) + + test("Config.InvalidError without path shows generic header", () => { + const err = { + name: "ConfigInvalidError", + data: { path: "config", issues: [] }, + } + const result = FormatError(err) + expect(result).toContain("Configuration is invalid") + // "config" path should not appear as a location qualifier + expect(result).not.toContain("at config") + }) + + test("UI.CancelledError returns empty string", () => { + const err = { name: "UICancelledError" } + const result = FormatError(err) + expect(result).toBe("") + }) + + test("unknown error returns undefined", () => { + const err = new Error("random error") + expect(FormatError(err)).toBeUndefined() + }) + + test("null input returns undefined", () => { + expect(FormatError(null)).toBeUndefined() + }) +}) + +describe("FormatUnknownError", () => { + test("Error with stack returns stack", () => { + const err = new Error("boom") + const result = FormatUnknownError(err) + expect(result).toContain("boom") + expect(result).toContain("Error") + }) + + test("Error without stack returns name + message", () => { + const err = new Error("boom") + err.stack = undefined + const result = FormatUnknownError(err) + expect(result).toBe("Error: boom") + }) + + test("plain object is JSON stringified", () => { + const result = FormatUnknownError({ code: 42, msg: "fail" }) + expect(result).toContain('"code": 42') + expect(result).toContain('"msg": "fail"') + }) + + test("circular object returns fallback message", () => { + const obj: any = { a: 1 } + obj.self = obj + const result = FormatUnknownError(obj) + expect(result).toBe("Unexpected error (unserializable)") + }) + + test("string input returns itself", () => { + expect(FormatUnknownError("something went wrong")).toBe("something went wrong") + }) + + test("number input returns string representation", () => { + expect(FormatUnknownError(404)).toBe("404") + }) + + test("undefined returns string 'undefined'", () => { + expect(FormatUnknownError(undefined)).toBe("undefined") + }) + + test("null returns string 'null'", () => { + expect(FormatUnknownError(null)).toBe("null") + }) +}) diff --git a/packages/opencode/test/cli/is-tool-on-path.test.ts b/packages/opencode/test/cli/is-tool-on-path.test.ts new file mode 100644 index 000000000..c5e8d1717 --- /dev/null +++ b/packages/opencode/test/cli/is-tool-on-path.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, test, expect } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import path from "path" +import { tmpdir } from "../fixture/fixture" +import { isToolOnPath } from "../../src/cli/cmd/skill-helpers" +import { Instance } from "../../src/project/instance" + +/** Create a tmpdir with git initialized (signing disabled for CI). */ +async function tmpdirGit(init?: (dir: string) => Promise) { + return tmpdir({ + init: async (dir) => { + await $`git init`.cwd(dir).quiet() + await $`git config core.fsmonitor false`.cwd(dir).quiet() + await $`git config commit.gpgsign false`.cwd(dir).quiet() + await $`git config user.email "test@opencode.test"`.cwd(dir).quiet() + await $`git config user.name "Test"`.cwd(dir).quiet() + await $`git commit --allow-empty -m "root"`.cwd(dir).quiet() + await init?.(dir) + }, + }) +} + +describe("isToolOnPath", () => { + const savedEnv: Record = {} + + afterEach(() => { + for (const [key, val] of Object.entries(savedEnv)) { + if (val === undefined) delete process.env[key] + else process.env[key] = val + } + Object.keys(savedEnv).forEach((k) => delete savedEnv[k]) + }) + + test("returns true when tool exists in .opencode/tools/ under cwd", async () => { + await using tmp = await tmpdirGit(async (dir) => { + const toolsDir = path.join(dir, ".opencode", "tools") + await fs.mkdir(toolsDir, { recursive: true }) + const toolPath = path.join(toolsDir, "my-test-tool") + await fs.writeFile(toolPath, "#!/bin/sh\necho ok\n") + await fs.chmod(toolPath, 0o755) + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const found = await isToolOnPath("my-test-tool", tmp.path) + expect(found).toBe(true) + }, + }) + }) + + test("returns false when tool does not exist anywhere", async () => { + savedEnv.ALTIMATE_BIN_DIR = process.env.ALTIMATE_BIN_DIR + delete process.env.ALTIMATE_BIN_DIR + + await using tmp = await tmpdirGit() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const found = await isToolOnPath("altimate-nonexistent-tool-xyz-99999", tmp.path) + expect(found).toBe(false) + }, + }) + }) + + test("returns true when tool is found via ALTIMATE_BIN_DIR", async () => { + await using tmp = await tmpdirGit(async (dir) => { + const binDir = path.join(dir, "custom-bin") + await fs.mkdir(binDir, { recursive: true }) + const toolPath = path.join(binDir, "my-bin-tool") + await fs.writeFile(toolPath, "#!/bin/sh\necho ok\n") + await fs.chmod(toolPath, 0o755) + }) + + savedEnv.ALTIMATE_BIN_DIR = process.env.ALTIMATE_BIN_DIR + process.env.ALTIMATE_BIN_DIR = path.join(tmp.path, "custom-bin") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const found = await isToolOnPath("my-bin-tool", tmp.path) + expect(found).toBe(true) + }, + }) + }) + + test("returns true when tool is on PATH via prepended directory", async () => { + await using tmp = await tmpdirGit(async (dir) => { + const pathDir = path.join(dir, "path-bin") + await fs.mkdir(pathDir, { recursive: true }) + const toolPath = path.join(pathDir, "my-path-tool") + await fs.writeFile(toolPath, "#!/bin/sh\necho ok\n") + await fs.chmod(toolPath, 0o755) + }) + + savedEnv.ALTIMATE_BIN_DIR = process.env.ALTIMATE_BIN_DIR + delete process.env.ALTIMATE_BIN_DIR + + savedEnv.PATH = process.env.PATH + const sep = process.platform === "win32" ? ";" : ":" + process.env.PATH = path.join(tmp.path, "path-bin") + sep + (process.env.PATH ?? "") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const found = await isToolOnPath("my-path-tool", tmp.path) + expect(found).toBe(true) + }, + }) + }) +}) diff --git a/packages/opencode/test/command/altimate-commands.test.ts b/packages/opencode/test/command/altimate-commands.test.ts new file mode 100644 index 000000000..1fd2bb7a9 --- /dev/null +++ b/packages/opencode/test/command/altimate-commands.test.ts @@ -0,0 +1,64 @@ +import { describe, test, expect } from "bun:test" +import { Command } from "../../src/command/index" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +async function withInstance(fn: () => Promise) { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ directory: tmp.path, fn }) +} + +describe("Altimate builtin commands", () => { + test("all altimate-specific commands are registered", async () => { + await withInstance(async () => { + const commands = await Command.list() + const names = commands.map((c) => c.name) + // These are the altimate_change commands that must ship with the package. + // Regression guard: commit 528af75 fixed discover-and-add-mcps not shipping. + expect(names).toContain("configure-claude") + expect(names).toContain("configure-codex") + expect(names).toContain("discover-and-add-mcps") + expect(names).toContain("feedback") + }) + }) + + test("Command.Default includes all altimate constants", () => { + expect(Command.Default.CONFIGURE_CLAUDE).toBe("configure-claude") + expect(Command.Default.CONFIGURE_CODEX).toBe("configure-codex") + expect(Command.Default.DISCOVER_MCPS).toBe("discover-and-add-mcps") + expect(Command.Default.FEEDBACK).toBe("feedback") + }) + + test("discover-and-add-mcps has correct metadata and template", async () => { + await withInstance(async () => { + const cmd = await Command.get("discover-and-add-mcps") + expect(cmd).toBeDefined() + expect(cmd.name).toBe("discover-and-add-mcps") + expect(cmd.source).toBe("command") + expect(cmd.description).toBe("discover MCP servers from external AI tool configs and add them") + const template = await cmd.template + expect(template).toContain("mcp_discover") + expect(cmd.hints).toContain("$ARGUMENTS") + }) + }) + + test("configure-claude has correct metadata", async () => { + await withInstance(async () => { + const cmd = await Command.get("configure-claude") + expect(cmd).toBeDefined() + expect(cmd.name).toBe("configure-claude") + expect(cmd.source).toBe("command") + expect(cmd.description).toBe("configure /altimate command in Claude Code") + }) + }) + + test("configure-codex has correct metadata", async () => { + await withInstance(async () => { + const cmd = await Command.get("configure-codex") + expect(cmd).toBeDefined() + expect(cmd.name).toBe("configure-codex") + expect(cmd.source).toBe("command") + expect(cmd.description).toBe("configure altimate skill in Codex CLI") + }) + }) +}) diff --git a/packages/opencode/test/command/hints.test.ts b/packages/opencode/test/command/hints.test.ts new file mode 100644 index 000000000..402046983 --- /dev/null +++ b/packages/opencode/test/command/hints.test.ts @@ -0,0 +1,62 @@ +import { describe, test, expect } from "bun:test" +import { Command } from "../../src/command" + +/** + * Tests for Command.hints() — the pure function that extracts argument + * placeholders ($1, $2, ..., $ARGUMENTS) from command templates. + * + * These hints drive the TUI's argument prompt display. If hints are wrong, + * users see incorrect or missing argument suggestions when invoking commands + * like /review, /init, or custom commands. + */ + +describe("Command.hints", () => { + test("returns empty array for template with no placeholders", () => { + expect(Command.hints("Run all tests")).toEqual([]) + }) + + test("extracts single numbered placeholder", () => { + expect(Command.hints("Review commit $1")).toEqual(["$1"]) + }) + + test("extracts multiple numbered placeholders in sorted order", () => { + expect(Command.hints("Compare $2 against $1")).toEqual(["$1", "$2"]) + }) + + test("deduplicates repeated placeholders", () => { + expect(Command.hints("Use $1 then reuse $1 again")).toEqual(["$1"]) + }) + + test("extracts $ARGUMENTS placeholder", () => { + expect(Command.hints("Execute with $ARGUMENTS")).toEqual(["$ARGUMENTS"]) + }) + + test("extracts both numbered and $ARGUMENTS, numbered first", () => { + expect(Command.hints("Run $1 with $ARGUMENTS")).toEqual(["$1", "$ARGUMENTS"]) + }) + + test("handles multi-digit placeholders like $10 (numeric sort)", () => { + const result = Command.hints("Lots of args: $1 $2 $10") + expect(result).toEqual(["$1", "$2", "$10"]) + }) + + test("returns empty for empty template string", () => { + expect(Command.hints("")).toEqual([]) + }) + + test("does not match $ followed by letters (not ARGUMENTS)", () => { + expect(Command.hints("Use $FOO and $BAR")).toEqual([]) + }) + + test("$ARGUMENTS is case-sensitive", () => { + expect(Command.hints("Use $arguments")).toEqual([]) + }) + + test("handles template with only whitespace", () => { + expect(Command.hints(" \n\t ")).toEqual([]) + }) + + test("handles $0 as a valid numbered placeholder", () => { + expect(Command.hints("$0 is first")).toEqual(["$0"]) + }) +}) diff --git a/packages/opencode/test/config/markdown.test.ts b/packages/opencode/test/config/markdown.test.ts index 865af2107..02851b456 100644 --- a/packages/opencode/test/config/markdown.test.ts +++ b/packages/opencode/test/config/markdown.test.ts @@ -211,6 +211,102 @@ Always structure your responses using clear markdown formatting: }) }) +describe("ConfigMarkdown.shell", () => { + test("extracts shell commands from template", () => { + const template = "Run !`git status` and then !`npm test`" + const matches = ConfigMarkdown.shell(template) + expect(matches.length).toBe(2) + expect(matches[0][1]).toBe("git status") + expect(matches[1][1]).toBe("npm test") + }) + + test("does not match regular backticks without bang", () => { + const template = "Use `git status` command" + const matches = ConfigMarkdown.shell(template) + expect(matches.length).toBe(0) + }) + + test("does not match empty bang-backtick", () => { + const template = "Empty !`` should not match" + const matches = ConfigMarkdown.shell(template) + expect(matches.length).toBe(0) + }) + + test("extracts command with pipes and flags", () => { + const template = "Run !`cat file.txt | grep error` to find issues" + const matches = ConfigMarkdown.shell(template) + expect(matches.length).toBe(1) + expect(matches[0][1]).toBe("cat file.txt | grep error") + }) +}) + +describe("ConfigMarkdown.fallbackSanitization", () => { + test("converts value with colon to block scalar", () => { + const input = "---\nurl: https://example.com:8080\n---\nContent" + const result = ConfigMarkdown.fallbackSanitization(input) + expect(result).toContain("url: |-") + expect(result).toContain(" https://example.com:8080") + }) + + test("preserves already double-quoted values with colons", () => { + const input = '---\nurl: "https://example.com:8080"\n---\nContent' + const result = ConfigMarkdown.fallbackSanitization(input) + expect(result).toContain('"https://example.com:8080"') + expect(result).not.toContain("|-") + }) + + test("preserves already single-quoted values with colons", () => { + const input = "---\nurl: 'https://example.com:8080'\n---\nContent" + const result = ConfigMarkdown.fallbackSanitization(input) + expect(result).toContain("'https://example.com:8080'") + expect(result).not.toContain("|-") + }) + + test("passes through content without frontmatter unchanged", () => { + const input = "Just some content" + expect(ConfigMarkdown.fallbackSanitization(input)).toBe(input) + }) + + test("preserves comments in frontmatter", () => { + const input = "---\n# comment\nname: John\n---\nContent" + const result = ConfigMarkdown.fallbackSanitization(input) + expect(result).toContain("# comment") + expect(result).toContain("name: John") + }) + + test("preserves indented continuation lines", () => { + const input = "---\nsummary: >\n This is multiline\n content\n---\nBody" + const result = ConfigMarkdown.fallbackSanitization(input) + expect(result).toContain(" This is multiline") + expect(result).toContain(" content") + }) + + test("preserves block scalar indicators", () => { + const input = "---\nnotes: |\n line1\n line2\n---\nContent" + const result = ConfigMarkdown.fallbackSanitization(input) + expect(result).toContain("notes: |") + }) + + test("handles empty frontmatter values", () => { + const input = "---\nempty:\n---\nContent" + const result = ConfigMarkdown.fallbackSanitization(input) + expect(result).toContain("empty:") + }) + + test("does not modify content after frontmatter", () => { + const input = "---\nname: John\n---\nContent with url: http://example.com:3000" + const result = ConfigMarkdown.fallbackSanitization(input) + expect(result).toContain("Content with url: http://example.com:3000") + }) + + test("handles CRLF line endings in frontmatter regex", () => { + const input = "---\r\nurl: https://example.com:8080\r\n---\r\nContent" + const result = ConfigMarkdown.fallbackSanitization(input) + expect(result).toContain("url: |-") + expect(result).toContain(" https://example.com:8080") + }) +}) + describe("ConfigMarkdown: frontmatter has weird model id", async () => { const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/weird-model-id.md") diff --git a/packages/opencode/test/patch/seek-sequence.test.ts b/packages/opencode/test/patch/seek-sequence.test.ts new file mode 100644 index 000000000..dc4873c75 --- /dev/null +++ b/packages/opencode/test/patch/seek-sequence.test.ts @@ -0,0 +1,240 @@ +import { describe, test, expect } from "bun:test" +import { Patch } from "../../src/patch" +import * as fs from "fs/promises" +import * as path from "path" +import { tmpdir } from "../fixture/fixture" + +/** + * Tests for Patch.deriveNewContentsFromChunks — the core function that applies + * update chunks to file content using seekSequence's multi-pass matching. + * + * seekSequence tries 4 comparison strategies in order: + * 1. Exact match + * 2. Trailing whitespace trimmed (rstrip) + * 3. Both-end whitespace trimmed (trim) + * 4. Unicode-normalized + trimmed + * + * These tests verify that real-world patch application succeeds even when the + * LLM-generated patch text has minor whitespace or Unicode differences from + * the actual file content — a common source of "Failed to find expected lines" + * errors for users. + */ + +describe("Patch.deriveNewContentsFromChunks — seekSequence matching", () => { + test("exact match: replaces old_lines with new_lines", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "exact.txt") + const content = "line1\nline2\nline3\n" + await fs.writeFile(filePath, content) + + const result = Patch.deriveNewContentsFromChunks(filePath, [ + { + old_lines: ["line2"], + new_lines: ["REPLACED"], + }, + ]) + + expect(result.content).toBe("line1\nREPLACED\nline3\n") + expect(result.unified_diff).toContain("-line2") + expect(result.unified_diff).toContain("+REPLACED") + }) + + test("rstrip pass: matches despite trailing whitespace differences", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "rstrip.txt") + const content = "line1\nline2 \nline3\n" + await fs.writeFile(filePath, content) + + const result = Patch.deriveNewContentsFromChunks(filePath, [ + { + old_lines: ["line2"], + new_lines: ["REPLACED"], + }, + ]) + + expect(result.content).toContain("REPLACED") + }) + + test("trim pass: matches despite leading whitespace differences", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "trim.txt") + const content = " function foo() {\n return 1\n }\n" + await fs.writeFile(filePath, content) + + const result = Patch.deriveNewContentsFromChunks(filePath, [ + { + old_lines: ["return 1"], + new_lines: ["return 42"], + }, + ]) + + expect(result.content).toContain("return 42") + }) + + test("unicode pass: matches smart quotes to ASCII quotes", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "unicode.txt") + const content = 'const msg = \u201CHello World\u201D\n' + await fs.writeFile(filePath, content) + + const result = Patch.deriveNewContentsFromChunks(filePath, [ + { + old_lines: ['const msg = "Hello World"'], + new_lines: ['const msg = "Goodbye World"'], + }, + ]) + + expect(result.content).toContain("Goodbye World") + }) + + test("unicode pass: matches em-dash to hyphen", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "emdash.txt") + const content = "value \u2014 description\n" + await fs.writeFile(filePath, content) + + const result = Patch.deriveNewContentsFromChunks(filePath, [ + { + old_lines: ["value - description"], + new_lines: ["value - updated"], + }, + ]) + + expect(result.content).toContain("updated") + }) + + test("is_end_of_file: anchors match to end of file", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "eof.txt") + const content = "line1\nline2\nline3\nline2\n" + await fs.writeFile(filePath, content) + + const result = Patch.deriveNewContentsFromChunks(filePath, [ + { + old_lines: ["line2"], + new_lines: ["LAST"], + is_end_of_file: true, + }, + ]) + + expect(result.content).toBe("line1\nline2\nline3\nLAST\n") + }) + + test("change_context: seeks to context line before matching old_lines", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "context.txt") + const content = "function foo() {\n return 1\n}\nfunction bar() {\n return 1\n}\n" + await fs.writeFile(filePath, content) + + const result = Patch.deriveNewContentsFromChunks(filePath, [ + { + old_lines: [" return 1"], + new_lines: [" return 99"], + change_context: "function bar() {", + }, + ]) + + expect(result.content).toContain("function foo() {\n return 1") + expect(result.content).toContain("function bar() {\n return 99") + }) + + test("throws when old_lines cannot be found", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "missing.txt") + await fs.writeFile(filePath, "hello\nworld\n") + + expect(() => + Patch.deriveNewContentsFromChunks(filePath, [ + { + old_lines: ["nonexistent line"], + new_lines: ["replacement"], + }, + ]), + ).toThrow("Failed to find expected lines") + }) + + test("throws when file does not exist", async () => { + await using tmp = await tmpdir() + expect(() => + Patch.deriveNewContentsFromChunks(path.join(tmp.path, "nonexistent-file.txt"), [ + { + old_lines: ["x"], + new_lines: ["y"], + }, + ]), + ).toThrow("Failed to read file") + }) + + test("multiple chunks applied in sequence", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "multi.txt") + const content = "alpha\nbeta\ngamma\ndelta\n" + await fs.writeFile(filePath, content) + + const result = Patch.deriveNewContentsFromChunks(filePath, [ + { + old_lines: ["beta"], + new_lines: ["BETA"], + }, + { + old_lines: ["delta"], + new_lines: ["DELTA"], + }, + ]) + + expect(result.content).toBe("alpha\nBETA\ngamma\nDELTA\n") + }) + + test("pure addition chunk (empty old_lines) appends content", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "append.txt") + await fs.writeFile(filePath, "existing\n") + + const result = Patch.deriveNewContentsFromChunks(filePath, [ + { + old_lines: [], + new_lines: ["new_line"], + }, + ]) + + expect(result.content).toContain("existing") + expect(result.content).toContain("new_line") + }) +}) + +describe("Patch.parsePatch — stripHeredoc handling", () => { + test("parses patch wrapped in heredoc with cat <<'EOF'", () => { + const input = `cat <<'EOF' +*** Begin Patch +*** Add File: hello.txt ++Hello +*** End Patch +EOF` + + const result = Patch.parsePatch(input) + expect(result.hunks).toHaveLength(1) + expect(result.hunks[0].type).toBe("add") + }) + + test("parses patch wrapped in heredoc with < { + const input = `< { + const input = `*** Begin Patch +*** Add File: test.txt ++content +*** End Patch` + + const result = Patch.parsePatch(input) + expect(result.hunks).toHaveLength(1) + }) +}) diff --git a/packages/opencode/test/skill/fmt.test.ts b/packages/opencode/test/skill/fmt.test.ts new file mode 100644 index 000000000..5659b6318 --- /dev/null +++ b/packages/opencode/test/skill/fmt.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect } from "bun:test" +import { Skill } from "../../src/skill/skill" + +function skill(overrides: Partial = {}): Skill.Info { + return { + name: overrides.name ?? "test-skill", + description: overrides.description ?? "A test skill", + location: overrides.location ?? "/home/user/skills/test-skill/SKILL.md", + content: overrides.content ?? "# Test\nDo the thing.", + } +} + +describe("Skill.fmt: skill list formatting", () => { + test("returns 'No skills' message for empty list", () => { + expect(Skill.fmt([], { verbose: false })).toBe("No skills are currently available.") + expect(Skill.fmt([], { verbose: true })).toBe("No skills are currently available.") + }) + + test("verbose mode returns XML with skill tags", () => { + const skills = [ + skill({ name: "analyze", description: "Analyze code", location: "/path/to/analyze/SKILL.md" }), + skill({ name: "deploy", description: "Deploy app", location: "/path/to/deploy/SKILL.md" }), + ] + const output = Skill.fmt(skills, { verbose: true }) + expect(output).toContain("") + expect(output).toContain("") + expect(output).toContain("analyze") + expect(output).toContain("Analyze code") + expect(output).toContain("deploy") + expect(output).toContain("Deploy app") + // File paths get converted to file:// URLs + expect(output).toContain("file:///path/to/analyze/SKILL.md") + }) + + test("non-verbose returns markdown with bullet points", () => { + const skills = [ + skill({ name: "lint", description: "Lint the code" }), + skill({ name: "format", description: "Format files" }), + ] + const output = Skill.fmt(skills, { verbose: false }) + expect(output).toContain("## Available Skills") + expect(output).toContain("- **lint**: Lint the code") + expect(output).toContain("- **format**: Format files") + }) + + test("verbose mode preserves builtin: protocol without file:// conversion", () => { + const skills = [ + skill({ name: "builtin-skill", description: "Built in", location: "builtin:my-skill/SKILL.md" }), + ] + const output = Skill.fmt(skills, { verbose: true }) + expect(output).toContain("builtin:my-skill/SKILL.md") + // Should NOT contain file:// for builtin: paths + expect(output).not.toContain("file://") + }) +}) diff --git a/packages/opencode/test/tool/batch.test.ts b/packages/opencode/test/tool/batch.test.ts new file mode 100644 index 000000000..3a4e519fb --- /dev/null +++ b/packages/opencode/test/tool/batch.test.ts @@ -0,0 +1,124 @@ +import { describe, test, expect } from "bun:test" +import { BatchTool } from "../../src/tool/batch" + +// BatchTool is a Tool.Info object; call .init() to get schema + helpers. +async function getToolInfo() { + return BatchTool.init() +} + +describe("BatchTool: schema validation", () => { + test("rejects empty tool_calls array", async () => { + const tool = await getToolInfo() + const result = tool.parameters.safeParse({ tool_calls: [] }) + expect(result.success).toBe(false) + }) + + test("accepts single tool call", async () => { + const tool = await getToolInfo() + const result = tool.parameters.safeParse({ + tool_calls: [{ tool: "read", parameters: { file_path: "/tmp/x" } }], + }) + expect(result.success).toBe(true) + }) + + test("accepts multiple tool calls", async () => { + const tool = await getToolInfo() + const result = tool.parameters.safeParse({ + tool_calls: [ + { tool: "read", parameters: { file_path: "/tmp/a" } }, + { tool: "grep", parameters: { pattern: "foo" } }, + ], + }) + expect(result.success).toBe(true) + }) + + test("rejects tool call without tool name", async () => { + const tool = await getToolInfo() + const result = tool.parameters.safeParse({ + tool_calls: [{ parameters: { file_path: "/tmp/x" } }], + }) + expect(result.success).toBe(false) + }) + + test("rejects tool call without parameters object", async () => { + const tool = await getToolInfo() + const result = tool.parameters.safeParse({ + tool_calls: [{ tool: "read" }], + }) + expect(result.success).toBe(false) + }) + + test("accepts tool call with empty parameters", async () => { + const tool = await getToolInfo() + const result = tool.parameters.safeParse({ + tool_calls: [{ tool: "read", parameters: {} }], + }) + expect(result.success).toBe(true) + }) +}) + +describe("BatchTool: formatValidationError", () => { + test("formatValidationError is defined", async () => { + const tool = await getToolInfo() + expect(tool.formatValidationError).toBeDefined() + }) + + test("produces readable error message for empty array", async () => { + const tool = await getToolInfo() + expect(tool.formatValidationError).toBeDefined() + const result = tool.parameters.safeParse({ tool_calls: [] }) + expect(result.success).toBe(false) + if (!result.success) { + const msg = tool.formatValidationError!(result.error) + expect(msg).toContain("Invalid parameters for tool 'batch'") + expect(msg).toContain("Expected payload format") + } + }) + + test("includes field path in type error", async () => { + const tool = await getToolInfo() + expect(tool.formatValidationError).toBeDefined() + const result = tool.parameters.safeParse({ + tool_calls: [{ tool: 123, parameters: {} }], + }) + expect(result.success).toBe(false) + if (!result.success) { + const msg = tool.formatValidationError!(result.error) + expect(msg).toContain("tool_calls") + } + }) +}) + +describe("BatchTool: DISALLOWED set enforcement", () => { + // The DISALLOWED set prevents recursive batch-in-batch calls. + // This is a critical safety mechanism — if the LLM can batch the batch tool, + // it creates infinite recursion. + // We verify the source code's DISALLOWED set by checking the module exports. + test("batch tool id is 'batch'", () => { + expect(BatchTool.id).toBe("batch") + }) + + // The 25-call cap and DISALLOWED enforcement happen inside execute(), + // which requires a full Session context. We verify the schema allows + // up to 25+ items at parse time (the cap is enforced at runtime). + test("schema accepts 25 tool calls (runtime cap is in execute)", async () => { + const tool = await getToolInfo() + const calls = Array.from({ length: 25 }, (_, i) => ({ + tool: `tool_${i}`, + parameters: {}, + })) + const result = tool.parameters.safeParse({ tool_calls: calls }) + expect(result.success).toBe(true) + }) + + test("schema accepts 26+ tool calls (runtime slices to 25)", async () => { + const tool = await getToolInfo() + const calls = Array.from({ length: 30 }, (_, i) => ({ + tool: `tool_${i}`, + parameters: {}, + })) + const result = tool.parameters.safeParse({ tool_calls: calls }) + // Schema allows it — the 25-cap is enforced in execute() + expect(result.success).toBe(true) + }) +}) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index aea0b1db8..0ec730eba 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -555,4 +555,149 @@ describe("filesystem", () => { expect(() => Filesystem.resolve(path.join(file, "child"))).toThrow() }) }) + + describe("findUp()", () => { + test("finds file at start directory", async () => { + await using tmp = await tmpdir() + const target = path.join(tmp.path, "AGENTS.md") + await fs.writeFile(target, "root instructions") + + const results = await Filesystem.findUp("AGENTS.md", tmp.path) + expect(results).toEqual([target]) + }) + + test("finds files at multiple levels walking upward", async () => { + await using tmp = await tmpdir() + // Create nested dirs: root/a/b/c + const dirA = path.join(tmp.path, "a") + const dirB = path.join(tmp.path, "a", "b") + const dirC = path.join(tmp.path, "a", "b", "c") + await fs.mkdir(dirC, { recursive: true }) + + // Place AGENTS.md at root and at a/b (but not at a or a/b/c) + await fs.writeFile(path.join(tmp.path, "AGENTS.md"), "root") + await fs.writeFile(path.join(dirB, "AGENTS.md"), "mid") + + // Start from a/b/c, walk up to root + const results = await Filesystem.findUp("AGENTS.md", dirC, tmp.path) + // Should find mid-level first (closer to start), then root + expect(results).toEqual([ + path.join(dirB, "AGENTS.md"), + path.join(tmp.path, "AGENTS.md"), + ]) + }) + + test("stop directory is inclusive (file at stop level is found)", async () => { + await using tmp = await tmpdir() + const child = path.join(tmp.path, "child") + await fs.mkdir(child) + await fs.writeFile(path.join(tmp.path, "AGENTS.md"), "at stop level") + + const results = await Filesystem.findUp("AGENTS.md", child, tmp.path) + expect(results).toEqual([path.join(tmp.path, "AGENTS.md")]) + }) + + test("returns empty when file does not exist anywhere", async () => { + await using tmp = await tmpdir() + const child = path.join(tmp.path, "nested") + await fs.mkdir(child) + + const results = await Filesystem.findUp("AGENTS.md", child, tmp.path) + expect(results).toEqual([]) + }) + + test("does not traverse past stop directory", async () => { + await using tmp = await tmpdir() + const stopDir = path.join(tmp.path, "stop") + const startDir = path.join(tmp.path, "stop", "deep") + await fs.mkdir(startDir, { recursive: true }) + + // Place file above the stop directory — should NOT be found + await fs.writeFile(path.join(tmp.path, "AGENTS.md"), "above stop") + + const results = await Filesystem.findUp("AGENTS.md", startDir, stopDir) + expect(results).toEqual([]) + }) + }) + + describe("globUp()", () => { + test("finds files matching glob pattern at multiple levels", async () => { + await using tmp = await tmpdir() + const child = path.join(tmp.path, "sub") + await fs.mkdir(child) + + await fs.writeFile(path.join(tmp.path, "notes.md"), "root notes") + await fs.writeFile(path.join(child, "readme.md"), "sub readme") + + const results = await Filesystem.globUp("*.md", child, tmp.path) + // Child level first, then root level + expect(results.length).toBe(2) + expect(results).toContain(path.join(child, "readme.md")) + expect(results).toContain(path.join(tmp.path, "notes.md")) + }) + + test("wildcard matches multiple files at one level", async () => { + await using tmp = await tmpdir() + await fs.writeFile(path.join(tmp.path, "a.md"), "a") + await fs.writeFile(path.join(tmp.path, "b.md"), "b") + await fs.writeFile(path.join(tmp.path, "c.txt"), "c") + + const results = await Filesystem.globUp("*.md", tmp.path, tmp.path) + expect(results.length).toBe(2) + expect(results.some((r) => r.endsWith("a.md"))).toBe(true) + expect(results.some((r) => r.endsWith("b.md"))).toBe(true) + }) + + test("returns empty when no matches exist", async () => { + await using tmp = await tmpdir() + const results = await Filesystem.globUp("*.md", tmp.path, tmp.path) + expect(results).toEqual([]) + }) + + test("does not traverse past stop directory", async () => { + await using tmp = await tmpdir() + const stopDir = path.join(tmp.path, "stop") + const startDir = path.join(tmp.path, "stop", "deep") + await fs.mkdir(startDir, { recursive: true }) + + await fs.writeFile(path.join(tmp.path, "notes.md"), "above stop") + + const results = await Filesystem.globUp("*.md", startDir, stopDir) + expect(results).toEqual([]) + }) + }) + + describe("overlaps()", () => { + test("same path overlaps with itself", () => { + expect(Filesystem.overlaps("/a/b", "/a/b")).toBe(true) + }) + + test("parent and child overlap", () => { + expect(Filesystem.overlaps("/a", "/a/b")).toBe(true) + expect(Filesystem.overlaps("/a/b", "/a")).toBe(true) + }) + + test("sibling directories do not overlap", () => { + expect(Filesystem.overlaps("/a/b", "/a/c")).toBe(false) + }) + + test("unrelated paths do not overlap", () => { + expect(Filesystem.overlaps("/foo", "/bar")).toBe(false) + }) + + test("path that is a string prefix but not an ancestor does not overlap", () => { + // /foo/bar and /foo/barbaz — "barbaz" starts with "bar" but is a sibling + expect(Filesystem.overlaps("/foo/bar", "/foo/barbaz")).toBe(false) + }) + + test("trailing slash does not affect result", () => { + expect(Filesystem.overlaps("/a/b/", "/a/b")).toBe(true) + expect(Filesystem.overlaps("/a/b", "/a/b/")).toBe(true) + }) + + test("root overlaps with everything", () => { + expect(Filesystem.overlaps("/", "/any/path")).toBe(true) + expect(Filesystem.overlaps("/any/path", "/")).toBe(true) + }) + }) }) diff --git a/packages/opencode/test/util/wildcard.test.ts b/packages/opencode/test/util/wildcard.test.ts index 56e753d12..3c675721b 100644 --- a/packages/opencode/test/util/wildcard.test.ts +++ b/packages/opencode/test/util/wildcard.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "bun:test" +import { describe, test, expect } from "bun:test" import { Wildcard } from "../../src/util/wildcard" test("match handles glob tokens", () => { @@ -88,3 +88,64 @@ test("match handles case-insensitivity on Windows", () => { expect(Wildcard.match("/users/test/file", "/Users/test/*")).toBe(false) } }) + +// --- Edge cases found during test-discovery audit --- + +describe("Wildcard.match — star crosses path separators", () => { + test("star matches across directory boundaries unlike shell glob", () => { + // Wildcard.match uses .* which crosses /, unlike shell globs where * stops at / + // This is relied on by the permission system: "src/*" must match "src/deep/nested/file.ts" + expect(Wildcard.match("src/deep/nested/file.ts", "src/*")).toBe(true) + expect(Wildcard.match("src/a/b/c/d.ts", "src/*/d.ts")).toBe(true) + }) +}) + +describe("Wildcard.match — special regex characters", () => { + test("dots in pattern are literal, not regex any-char", () => { + expect(Wildcard.match("file.txt", "file.txt")).toBe(true) + expect(Wildcard.match("filextxt", "file.txt")).toBe(false) + }) + + test("parentheses and pipes in pattern are literal", () => { + expect(Wildcard.match("(a|b)", "(a|b)")).toBe(true) + expect(Wildcard.match("a", "(a|b)")).toBe(false) + }) + + test("brackets in pattern are literal", () => { + expect(Wildcard.match("[abc]", "[abc]")).toBe(true) + expect(Wildcard.match("a", "[abc]")).toBe(false) + }) + + test("dollar and caret in pattern are literal", () => { + expect(Wildcard.match("$HOME", "$HOME")).toBe(true) + expect(Wildcard.match("^start", "^start")).toBe(true) + }) +}) + +describe("Wildcard.match — empty and boundary cases", () => { + test("empty pattern matches only empty string", () => { + expect(Wildcard.match("", "")).toBe(true) + expect(Wildcard.match("something", "")).toBe(false) + }) +}) + +describe("Wildcard.allStructured — non-contiguous tail matching", () => { + test("non-contiguous tail tokens match if in correct order", () => { + // matchSequence scans non-contiguously: finds "push" then skips "extra" and finds "--force" + const result = Wildcard.allStructured( + { head: "git", tail: ["push", "extra", "--force"] }, + { "git push --force": "deny" }, + ) + expect(result).toBe("deny") + }) + + test("reversed tail tokens do not match when items exhausted", () => { + // Pattern expects push then --force; tail has them reversed + // "push" found at i=1, but no items remain after for "--force" + const result = Wildcard.allStructured( + { head: "git", tail: ["--force", "push"] }, + { "git push --force": "deny" }, + ) + expect(result).toBeUndefined() + }) +})