opencode-fmt 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/debug.ts +16 -0
- package/package.json +26 -0
- package/src/config.ts +65 -0
- package/src/emoji-sanitizer.ts +7 -0
- package/src/engine.ts +52 -0
- package/src/index.ts +59 -0
- package/src/metrics.ts +9 -0
- package/src/passes/echo.ts +11 -0
- package/src/pipeline.ts +21 -0
- package/src/table-aligner.ts +86 -0
- package/tests/aligner.test.ts +100 -0
- package/tests/benchmarks.test.ts +32 -0
- package/tests/config.test.ts +77 -0
- package/tests/engine.test.ts +136 -0
- package/tests/index.test.ts +183 -0
- package/tsconfig.json +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ritesh Kumar Pal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# OpenCode FMT Plugin
|
|
2
|
+
|
|
3
|
+
A unified, single-pass text optimization plugin for OpenCode that sanitizes emojis and formats markdown tables deterministically.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
1. **Deterministic Emoji Sanitizer:** Surgically removes emojis from AI outputs without altering the surrounding whitespace or layout.
|
|
8
|
+
2. **Isolated Table Aligner:** Formats Markdown tables by calculating maximum column widths and applying deterministic padding, ensuring visual correctness.
|
|
9
|
+
3. **Zero-Dependency Engine:** Processes the AI output in a single pass ($O(n)$ latency) without generating an AST or relying on third-party libraries like Prettier or Remark.
|
|
10
|
+
4. **Flat Configuration:** Simple, flat boolean configuration file with nested-object crash safety.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
This is an OpenCode-native TypeScript plugin. OpenCode executes it via Bun.
|
|
15
|
+
|
|
16
|
+
There are no external parsing dependencies required.
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
// package.json (dev setup)
|
|
20
|
+
{
|
|
21
|
+
"name": "opencode-postgen-pipeline",
|
|
22
|
+
"private": true,
|
|
23
|
+
"type": "module",
|
|
24
|
+
"packageManager": "bun@1.3.11",
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "5.9.3",
|
|
27
|
+
"bun-types": "latest"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Create a `config.json` inside your plugin root directory. The plugin only accepts a flat structure.
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"stripEmojis": true,
|
|
39
|
+
"alignTables": true,
|
|
40
|
+
"logMetrics": false
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Performance
|
|
45
|
+
|
|
46
|
+
To prove the core value proposition of an $O(n)$ single pass, this plugin includes latency telemetry.
|
|
47
|
+
|
|
48
|
+
**Benchmark:**
|
|
49
|
+
Processing a highly-mixed payload of 10,000 words containing text, emojis, and tables:
|
|
50
|
+
* **Result:** `~4.0ms` (Target limit: `< 50ms`)
|
|
51
|
+
|
|
52
|
+
## Architectural Constraints (Strict)
|
|
53
|
+
|
|
54
|
+
This plugin is governed by strict, locked technical rules to preserve its performance:
|
|
55
|
+
1. **Single-pass only:** No second iterations over the buffer.
|
|
56
|
+
2. **Zero runtime dependencies.**
|
|
57
|
+
3. **Flat config:** No nested structures or custom AST rules.
|
|
58
|
+
4. **Diff Stability:** Non-target text (pure paragraphs) is preserved byte-for-byte in the output to avoid noisy Git diffs.
|
package/debug.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { alignTable } from "./src/table-aligner";
|
|
2
|
+
|
|
3
|
+
const inputCenter = ["| Item | Amount |", "|:---:|----:|", "| a | long |"];
|
|
4
|
+
console.log("Center test: ");
|
|
5
|
+
const resultCenter = alignTable(inputCenter);
|
|
6
|
+
console.log(resultCenter.join("\n"));
|
|
7
|
+
|
|
8
|
+
const inputRight = ["| Name | Score |", "|---:|-----:|", "| Alice | 100 |"];
|
|
9
|
+
console.log("\nRight test: ");
|
|
10
|
+
const resultRight = alignTable(inputRight);
|
|
11
|
+
console.log(resultRight.join("\n"));
|
|
12
|
+
|
|
13
|
+
const inputMixed = ["| Left | Center | Right |", "| :--- | :---: | ---: |", "| l | center | rvalue |"];
|
|
14
|
+
console.log("\nMixed test: ");
|
|
15
|
+
const resultMixed = alignTable(inputMixed);
|
|
16
|
+
console.log(resultMixed.join("\n"));
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-fmt",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"private": false,
|
|
10
|
+
"packageManager": "bun@1.3.11",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "bun run src/index.ts",
|
|
13
|
+
"build": "bun build src/index.ts --outfile=dist/index.js --target=node --format=esm --external=@opencode-ai/plugin",
|
|
14
|
+
"test": "bun test",
|
|
15
|
+
"test:coverage": "bun test --coverage"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@opencode-ai/plugin": "^1.2.27",
|
|
19
|
+
"@types/bun": "^1.3.11",
|
|
20
|
+
"@types/node": "^25.5.0",
|
|
21
|
+
"typescript": "5.9.3"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"typescript": "^5"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface PluginConfig {
|
|
5
|
+
stripEmojis: boolean;
|
|
6
|
+
alignTables: boolean;
|
|
7
|
+
logging: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_CONFIG: PluginConfig = {
|
|
11
|
+
stripEmojis: true,
|
|
12
|
+
alignTables: true,
|
|
13
|
+
logging: false,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function loadConfig(pluginDir: string): PluginConfig {
|
|
17
|
+
const configPath = path.join(pluginDir, "config.json");
|
|
18
|
+
|
|
19
|
+
if (!fs.existsSync(configPath)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`[opencode-fmt] Configuration file "config.json" is missing. Please create it in the project root.`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let rawConfig: Record<string, unknown> = {};
|
|
26
|
+
try {
|
|
27
|
+
const fileContent = fs.readFileSync(configPath, "utf-8");
|
|
28
|
+
const parsedConfig = JSON.parse(fileContent);
|
|
29
|
+
if (typeof parsedConfig === "object" && parsedConfig !== null) {
|
|
30
|
+
rawConfig = parsedConfig as Record<string, unknown>;
|
|
31
|
+
} else {
|
|
32
|
+
throw new Error("Config file content is not a valid JSON object.");
|
|
33
|
+
}
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`[opencode-fmt] Failed to parse config.json: ${err instanceof Error ? err.message : String(err)}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const finalConfig: PluginConfig = { ...DEFAULT_CONFIG };
|
|
41
|
+
|
|
42
|
+
for (const key of Object.keys(DEFAULT_CONFIG) as Array<keyof PluginConfig>) {
|
|
43
|
+
if (Object.hasOwn(rawConfig, key)) {
|
|
44
|
+
const value = rawConfig[key];
|
|
45
|
+
if (typeof value === typeof DEFAULT_CONFIG[key]) {
|
|
46
|
+
(finalConfig[key] as unknown) = value;
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[opencode-fmt] Config Error: Invalid type for "${key}". Expected ${typeof DEFAULT_CONFIG[key]}, got ${typeof value}.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const [key, value] of Object.entries(rawConfig)) {
|
|
56
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`[opencode-fmt] Config Error: Nested object at "${key}". Only flat keys allowed.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
(finalConfig as unknown as Record<string, unknown>)[key] = value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return finalConfig as PluginConfig;
|
|
65
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PluginConfig } from "./config";
|
|
2
|
+
import { sanitizeEmojis } from "./emoji-sanitizer";
|
|
3
|
+
import { alignTable } from "./table-aligner";
|
|
4
|
+
|
|
5
|
+
export function runPipeline(text: string, config: PluginConfig): string {
|
|
6
|
+
if (!text) return text;
|
|
7
|
+
|
|
8
|
+
const lines = text.split("\n");
|
|
9
|
+
const resultLines: string[] = [];
|
|
10
|
+
|
|
11
|
+
let isInTable = false;
|
|
12
|
+
let tableBuffer: string[] = [];
|
|
13
|
+
|
|
14
|
+
const flushTable = () => {
|
|
15
|
+
if (tableBuffer.length > 0) {
|
|
16
|
+
const output = config.alignTables ? alignTable(tableBuffer) : tableBuffer;
|
|
17
|
+
resultLines.push(...output);
|
|
18
|
+
tableBuffer = [];
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
let processedLine = line;
|
|
24
|
+
|
|
25
|
+
if (config.stripEmojis) {
|
|
26
|
+
processedLine = sanitizeEmojis(processedLine);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isTableRow = processedLine.trim().startsWith("|");
|
|
30
|
+
|
|
31
|
+
if (isTableRow) {
|
|
32
|
+
isInTable = true;
|
|
33
|
+
tableBuffer.push(processedLine);
|
|
34
|
+
} else {
|
|
35
|
+
if (isInTable) {
|
|
36
|
+
flushTable();
|
|
37
|
+
isInTable = false;
|
|
38
|
+
}
|
|
39
|
+
resultLines.push(processedLine);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
flushTable();
|
|
44
|
+
|
|
45
|
+
const result = resultLines.join("\n");
|
|
46
|
+
|
|
47
|
+
if (text.trim().length > 0 && result.trim().length === 0) {
|
|
48
|
+
return "[Only emojis received and redacted by opencode-fmt]";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return result;
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
5
|
+
import { loadConfig, type PluginConfig } from "./config";
|
|
6
|
+
import { runPipeline } from "./engine";
|
|
7
|
+
import { measureExecution } from "./metrics";
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
const PLUGIN_DIR = path.join(__dirname, "..");
|
|
12
|
+
|
|
13
|
+
export type { PluginInput };
|
|
14
|
+
|
|
15
|
+
export const OpenCodeFmt: Plugin = async (_input: PluginInput) => {
|
|
16
|
+
const logPath = path.join(PLUGIN_DIR, "opencode-fmt.log");
|
|
17
|
+
|
|
18
|
+
let config: PluginConfig;
|
|
19
|
+
try {
|
|
20
|
+
config = loadConfig(PLUGIN_DIR);
|
|
21
|
+
if (config.logging) {
|
|
22
|
+
const bootMsg = `[opencode-fmt] ${new Date().toLocaleString()} | Plugin Started | Config: ${JSON.stringify(config)}\n`;
|
|
23
|
+
await fs.appendFile(logPath, bootMsg).catch(() => {});
|
|
24
|
+
}
|
|
25
|
+
} catch (err) {
|
|
26
|
+
const fatalMsg = `[opencode-fmt] ${new Date().toLocaleString()} | FATAL STARTUP ERROR: ${err instanceof Error ? err.message : String(err)}\n`;
|
|
27
|
+
await fs.appendFile(logPath, fatalMsg).catch(() => {});
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"experimental.text.complete": async (_hookInput, hookOutput) => {
|
|
33
|
+
const rawText = hookOutput.text || "";
|
|
34
|
+
|
|
35
|
+
const { result: processedText, duration } = measureExecution(() =>
|
|
36
|
+
runPipeline(rawText, config),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
hookOutput.text = processedText;
|
|
40
|
+
|
|
41
|
+
if (config.logging) {
|
|
42
|
+
try {
|
|
43
|
+
const timestamp = new Date().toLocaleString();
|
|
44
|
+
let logMsg = "";
|
|
45
|
+
|
|
46
|
+
if (rawText === processedText) {
|
|
47
|
+
logMsg = `[opencode-fmt] ${timestamp} | Duration: ${duration}ms | Hook triggered | Response: ${JSON.stringify(rawText)}\n`;
|
|
48
|
+
} else {
|
|
49
|
+
logMsg = `[opencode-fmt] ${timestamp} | Duration: ${duration}ms | Hook triggered | Raw: ${JSON.stringify(rawText)} | Cleaned: ${JSON.stringify(processedText)}\n`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fs.appendFile(logPath, logMsg).catch(() => {});
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
} as Hooks;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export default OpenCodeFmt;
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Config, Pass } from "../config.js";
|
|
2
|
+
|
|
3
|
+
export const echoPass: Pass = {
|
|
4
|
+
name: "echo",
|
|
5
|
+
enabled: () => true,
|
|
6
|
+
transform: (text: string, _cfg: Config) => {
|
|
7
|
+
process.stderr.write(`opencode-fmt: echo — received ${text.length} chars\n`);
|
|
8
|
+
process.stderr.write(`opencode-fmt: echo — received text:\n${text}\n---\n`);
|
|
9
|
+
return text;
|
|
10
|
+
}
|
|
11
|
+
};
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Config, Pass } from "./config.js";
|
|
2
|
+
import { echoPass } from "./passes/echo.js";
|
|
3
|
+
|
|
4
|
+
const passes: Pass[] = [
|
|
5
|
+
echoPass,
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export function runPipeline(text: string, cfg: Config): string {
|
|
9
|
+
if (!text || passes.length === 0) return text;
|
|
10
|
+
|
|
11
|
+
let result = text;
|
|
12
|
+
|
|
13
|
+
for (let i = 0, len = passes.length; i < len; i++) {
|
|
14
|
+
const pipe = passes[i];
|
|
15
|
+
if (pipe.enabled(cfg)) {
|
|
16
|
+
result = pipe.transform(result, cfg);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
type Alignment = "default" | "left" | "center" | "right";
|
|
2
|
+
|
|
3
|
+
function parseAlignment(cell: string): Alignment {
|
|
4
|
+
const trimmed = cell.trim();
|
|
5
|
+
const leftColon = trimmed.startsWith(":");
|
|
6
|
+
const rightColon = trimmed.endsWith(":");
|
|
7
|
+
|
|
8
|
+
if (leftColon && rightColon) return "center";
|
|
9
|
+
if (rightColon) return "right";
|
|
10
|
+
if (leftColon) return "left";
|
|
11
|
+
return "default";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatSeparator(width: number, align: Alignment): string {
|
|
15
|
+
const w = Math.max(3, width);
|
|
16
|
+
if (align === "center") return ":" + "-".repeat(w - 2) + ":";
|
|
17
|
+
if (align === "right") return "-".repeat(w - 1) + ":";
|
|
18
|
+
if (align === "left") return ":" + "-".repeat(w - 1);
|
|
19
|
+
return "-".repeat(w);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function padCell(cell: string, width: number, align: Alignment): string {
|
|
23
|
+
const padding = Math.max(0, width - cell.length);
|
|
24
|
+
|
|
25
|
+
if (align === "center") {
|
|
26
|
+
const leftPad = Math.floor(padding / 2);
|
|
27
|
+
const rightPad = padding - leftPad;
|
|
28
|
+
return " ".repeat(leftPad) + cell + " ".repeat(rightPad);
|
|
29
|
+
}
|
|
30
|
+
if (align === "right") {
|
|
31
|
+
return " ".repeat(padding) + cell;
|
|
32
|
+
}
|
|
33
|
+
return cell + " ".repeat(padding);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function alignTable(rows: string[]): string[] {
|
|
37
|
+
const rowCount = rows.length;
|
|
38
|
+
if (rowCount === 0) return rows;
|
|
39
|
+
|
|
40
|
+
const tableData = new Array(rowCount);
|
|
41
|
+
const colWidths: number[] = [];
|
|
42
|
+
const colAlignments: Alignment[] = [];
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < rowCount; i++) {
|
|
45
|
+
const row = rows[i];
|
|
46
|
+
let trimmedRow = row.trim();
|
|
47
|
+
if (trimmedRow.startsWith("|")) trimmedRow = trimmedRow.slice(1);
|
|
48
|
+
if (trimmedRow.endsWith("|")) trimmedRow = trimmedRow.slice(0, -1);
|
|
49
|
+
|
|
50
|
+
const cells = trimmedRow.split("|").map((c) => c.trim());
|
|
51
|
+
const isSeparator = cells.length > 0 && cells.every((c) => /^\s*:?-+:?\s*$/.test(c));
|
|
52
|
+
|
|
53
|
+
tableData[i] = { cells, isSeparator };
|
|
54
|
+
|
|
55
|
+
if (isSeparator) {
|
|
56
|
+
for (let j = 0; j < cells.length; j++) {
|
|
57
|
+
colAlignments[j] = parseAlignment(cells[j]);
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
for (let j = 0; j < cells.length; j++) {
|
|
61
|
+
const cellLength = cells[j].length;
|
|
62
|
+
if (colWidths[j] === undefined || cellLength > colWidths[j]) {
|
|
63
|
+
colWidths[j] = cellLength;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const colCount = colWidths.length;
|
|
70
|
+
|
|
71
|
+
return tableData.map((row) => {
|
|
72
|
+
const paddedCells = new Array(colCount);
|
|
73
|
+
for (let i = 0; i < colCount; i++) {
|
|
74
|
+
const cell = row.cells[i] || "";
|
|
75
|
+
const width = colWidths[i] || 0;
|
|
76
|
+
const align = colAlignments[i] || "default";
|
|
77
|
+
|
|
78
|
+
if (row.isSeparator) {
|
|
79
|
+
paddedCells[i] = formatSeparator(width, align);
|
|
80
|
+
} else {
|
|
81
|
+
paddedCells[i] = padCell(cell, width, align);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return `| ${paddedCells.join(" | ")} |`;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { runPipeline } from "../src/engine";
|
|
3
|
+
import { alignTable } from "../src/table-aligner";
|
|
4
|
+
|
|
5
|
+
describe("Table Aligner", () => {
|
|
6
|
+
test("aligns basic table", () => {
|
|
7
|
+
const input = ["| Header 1 | H2 |", "|---|---|", "| Val | Long Value |"];
|
|
8
|
+
const expected = [
|
|
9
|
+
"| Header 1 | H2 |",
|
|
10
|
+
"| -------- | ---------- |",
|
|
11
|
+
"| Val | Long Value |",
|
|
12
|
+
];
|
|
13
|
+
expect(alignTable(input)).toEqual(expected);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("handles misaligned input pipes", () => {
|
|
17
|
+
const input = ["| Key | Value|", "|---|---|", "|a|b|"];
|
|
18
|
+
const expected = ["| Key | Value |", "| --- | ----- |", "| a | b |"];
|
|
19
|
+
expect(alignTable(input)).toEqual(expected);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("fills missing columns in short rows", () => {
|
|
23
|
+
const input = ["| H1 | H2 |", "|---|---|", "| Single |"];
|
|
24
|
+
const result = alignTable(input);
|
|
25
|
+
expect(result[0]).toBe("| H1 | H2 |");
|
|
26
|
+
expect(result[1]).toBe("| ------ | --- |");
|
|
27
|
+
expect(result[2]).toBe("| Single | |");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("handles empty input", () => {
|
|
31
|
+
expect(alignTable([])).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("preserves center alignment", () => {
|
|
35
|
+
const input = ["| Item | Amount |", "|:---:|----:|", "| a | long |"];
|
|
36
|
+
const result = alignTable(input);
|
|
37
|
+
expect(result[0]).toBe("| Item | Amount |");
|
|
38
|
+
expect(result[1]).toBe("| :----: | ------: |");
|
|
39
|
+
expect(result[2]).toBe("| a | long |");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("preserves right alignment", () => {
|
|
43
|
+
const input = ["| Name | Score |", "|---:|-----:|", "| Alice | 100 |"];
|
|
44
|
+
const result = alignTable(input);
|
|
45
|
+
expect(result[0]).toBe("| Name | Score |");
|
|
46
|
+
expect(result[1]).toBe("| -----: | -----: |");
|
|
47
|
+
expect(result[2]).toBe("| Alice | 100 |");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("preserves mixed alignment", () => {
|
|
51
|
+
const input = ["| Left | Center | Right |", "| :--- | :---: | ---: |", "| l | center | rvalue |"];
|
|
52
|
+
const result = alignTable(input);
|
|
53
|
+
expect(result[1]).toBe("| :--- | :-----: | ------: |");
|
|
54
|
+
expect(result[2]).toBe("| l | center | rvalue |");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("Diff Stability", () => {
|
|
59
|
+
const cfg = { stripEmojis: false, alignTables: true, logMetrics: false };
|
|
60
|
+
|
|
61
|
+
test("plain text with no tables passes through byte-identical", () => {
|
|
62
|
+
const input = "This is plain text.\nNo tables here.\nJust three lines.";
|
|
63
|
+
expect(runPipeline(input, cfg)).toBe(input);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("only table lines change in a mixed document", () => {
|
|
67
|
+
const before = "Intro line — unchanged.";
|
|
68
|
+
const table = "| A | B |\n|---|---|\n| 1 | 2 |";
|
|
69
|
+
const after = "Outro line — unchanged.";
|
|
70
|
+
const input = `${before}\n${table}\n${after}`;
|
|
71
|
+
|
|
72
|
+
const result = runPipeline(input, cfg);
|
|
73
|
+
const lines = result.split("\n");
|
|
74
|
+
|
|
75
|
+
expect(lines[0]).toBe(before);
|
|
76
|
+
expect(lines[lines.length - 1]).toBe(after);
|
|
77
|
+
|
|
78
|
+
const rawTableLines = table.split("\n");
|
|
79
|
+
const resultTableLines = lines.slice(1, lines.length - 1);
|
|
80
|
+
expect(resultTableLines).not.toEqual(rawTableLines);
|
|
81
|
+
|
|
82
|
+
expect(resultTableLines[0]).toContain("A");
|
|
83
|
+
expect(resultTableLines[0]).toContain("B");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("multiple non-table blocks between tables are untouched", () => {
|
|
87
|
+
const header = "# Title";
|
|
88
|
+
const para = "Some paragraph text here.";
|
|
89
|
+
const table = "| X | Y |\n|---|---|\n| a | b |";
|
|
90
|
+
const footer = "Footer text.";
|
|
91
|
+
const input = `${header}\n${para}\n${table}\n${footer}`;
|
|
92
|
+
|
|
93
|
+
const result = runPipeline(input, cfg);
|
|
94
|
+
const lines = result.split("\n");
|
|
95
|
+
|
|
96
|
+
expect(lines[0]).toBe(header);
|
|
97
|
+
expect(lines[1]).toBe(para);
|
|
98
|
+
expect(lines[lines.length - 1]).toBe(footer);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DEFAULT_CONFIG } from "../src/config";
|
|
3
|
+
import { runPipeline } from "../src/engine";
|
|
4
|
+
|
|
5
|
+
describe("Performance Benchmarks (O(n) Constraint)", () => {
|
|
6
|
+
test("processes 10,000 mixed words in under 50ms", () => {
|
|
7
|
+
const paragraph =
|
|
8
|
+
"This is a standard paragraph containing some words. Here is an emoji 🚀 to make things interesting. Let's add some more text to bulk this out so we can truly stress test the single-pass nature of the traverse. We also have female pilots 👩✈️ and some flags 🇺🇸.\n";
|
|
9
|
+
const tableBlock =
|
|
10
|
+
"\n| Id | Name | Role |\n|---|---|\n| 1 | Alice 🦸♀️ | Admin |\n| 2 | Bob (No emoji) | User |\n| 3 | Charlie 👨💻 | Mod |\n";
|
|
11
|
+
|
|
12
|
+
let payload = "";
|
|
13
|
+
for (let i = 0; i < 200; i++) {
|
|
14
|
+
payload += paragraph;
|
|
15
|
+
if (i % 5 === 0) payload += tableBlock;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const wordCount = payload.split(/\s+/).length;
|
|
19
|
+
expect(wordCount).toBeGreaterThanOrEqual(10000);
|
|
20
|
+
|
|
21
|
+
const start = performance.now();
|
|
22
|
+
const result = runPipeline(payload, DEFAULT_CONFIG);
|
|
23
|
+
const durationMs = performance.now() - start;
|
|
24
|
+
|
|
25
|
+
console.log(
|
|
26
|
+
`[Benchmark] 10,000 words processed in ${durationMs.toFixed(2)}ms`,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(durationMs).toBeLessThan(50);
|
|
30
|
+
expect(result.includes("🚀")).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
mock,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { DEFAULT_CONFIG, loadConfig } from "../src/config";
|
|
13
|
+
|
|
14
|
+
describe("Config Loader", () => {
|
|
15
|
+
const dir = "/fake/dir";
|
|
16
|
+
const _configPath = path.join(dir, "config.json");
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
spyOn(fs, "existsSync");
|
|
20
|
+
spyOn(fs, "readFileSync");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
mock.restore();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("throws error if config.json is missing", () => {
|
|
28
|
+
spyOn(fs, "existsSync").mockReturnValue(false);
|
|
29
|
+
expect(() => loadConfig(dir)).toThrow(
|
|
30
|
+
/Configuration file "config.json" is missing/,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("throws error if config.json is invalid JSON", () => {
|
|
35
|
+
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
36
|
+
spyOn(fs, "readFileSync").mockReturnValue("{ bad json ]");
|
|
37
|
+
expect(() => loadConfig(dir)).toThrow(/Failed to parse config.json/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("loads config and merges with defaults", () => {
|
|
41
|
+
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
42
|
+
spyOn(fs, "readFileSync").mockReturnValue(
|
|
43
|
+
JSON.stringify({ logging: true }),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const config = loadConfig(dir);
|
|
47
|
+
|
|
48
|
+
expect(config.logging).toBe(true);
|
|
49
|
+
expect(config.stripEmojis).toBe(DEFAULT_CONFIG.stripEmojis);
|
|
50
|
+
expect(config.alignTables).toBe(DEFAULT_CONFIG.alignTables);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("throws error if config contains nested objects", () => {
|
|
54
|
+
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
55
|
+
spyOn(fs, "readFileSync").mockReturnValue(
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
stripEmojis: true,
|
|
58
|
+
nested: { deeply: true },
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(() => loadConfig(dir)).toThrow(/Nested object at "nested"/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("allows arrays in config (e.g. for future list features)", () => {
|
|
66
|
+
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
67
|
+
spyOn(fs, "readFileSync").mockReturnValue(
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
stripEmojis: true,
|
|
70
|
+
ignoredPaths: ["node_modules"],
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const config = loadConfig(dir) as Record<string, unknown>;
|
|
75
|
+
expect(config.ignoredPaths).toEqual(["node_modules"]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { runPipeline } from "../src/engine";
|
|
3
|
+
|
|
4
|
+
const cfg = { stripEmojis: true, alignTables: true, logMetrics: false };
|
|
5
|
+
const cfgOff = { stripEmojis: false, alignTables: false, logMetrics: false };
|
|
6
|
+
|
|
7
|
+
describe("Emoji Sanitizer", () => {
|
|
8
|
+
test("strips a single emoji", () => {
|
|
9
|
+
expect(runPipeline("Hello 👋", cfg)).toBe("Hello ");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("strips multiple emojis in a single pass", () => {
|
|
13
|
+
expect(runPipeline("🚀 Launch 🎉 Done", cfg)).toBe(" Launch Done");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("leaves plain text untouched", () => {
|
|
17
|
+
expect(runPipeline("No emojis here", cfg)).toBe("No emojis here");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("empty string stays empty", () => {
|
|
21
|
+
expect(runPipeline("", cfg)).toBe("");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("strips emojis at start and end of string", () => {
|
|
25
|
+
expect(runPipeline("🔥 hot take 🔥", cfg)).toBe(" hot take ");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("returns redaction placeholder for emoji-only string", () => {
|
|
29
|
+
expect(runPipeline("😀🎉🚀", cfg)).toBe(
|
|
30
|
+
"[Only emojis received and redacted by opencode-fmt]",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("returns redaction placeholder for emoji-only string with spaces", () => {
|
|
35
|
+
expect(runPipeline(" 🚀 ", cfg)).toBe(
|
|
36
|
+
"[Only emojis received and redacted by opencode-fmt]",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("preserves numbers and punctuation around emojis", () => {
|
|
41
|
+
expect(runPipeline("Step 1: 📋 check", cfg)).toBe("Step 1: check");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("preserves newlines and multiline content", () => {
|
|
45
|
+
expect(runPipeline("Line 1 🌍\nLine 2 🌏", cfg)).toBe("Line 1 \nLine 2 ");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("strips skin-tone modifier sequences", () => {
|
|
49
|
+
expect(runPipeline("Hi 👋🏾 there", cfg)).toBe("Hi there");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("strips ZWJ family emoji sequences", () => {
|
|
53
|
+
expect(runPipeline("Family: 👨👩👧👦", cfg)).toBe("Family: ");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("strips Unicode flag emojis", () => {
|
|
57
|
+
expect(runPipeline("🇮🇳 India", cfg)).toBe(" India");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("strips Keycap sequence emojis like 1️⃣", () => {
|
|
61
|
+
expect(runPipeline("Count: 1️⃣ 2️⃣", cfg)).toBe("Count: 1 2");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("strips dingbat emojis (Supplemental Symbols)", () => {
|
|
65
|
+
expect(runPipeline("Status: ✅ OK ❌ Fail", cfg)).toBe("Status: OK Fail");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("strips extended pictographic symbols like 🤖 and 🦾", () => {
|
|
69
|
+
expect(runPipeline("AI 🤖 is 🦾 strong", cfg)).toBe("AI is strong");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("strips emojis from markdown headings", () => {
|
|
73
|
+
expect(runPipeline("# Hello 👋 World", cfg)).toBe("# Hello World");
|
|
74
|
+
expect(runPipeline("### Feature 🚀", cfg)).toBe("### Feature ");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("strips emojis from markdown bold and italic text", () => {
|
|
78
|
+
expect(runPipeline("**Bold 💥 text** and *Italic 📝*", cfg)).toBe(
|
|
79
|
+
"**Bold text** and *Italic *",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("strips emojis from markdown lists (unordered and ordered)", () => {
|
|
84
|
+
expect(runPipeline("- Item 1 🍎\n- Item 2 🍌", cfg)).toBe(
|
|
85
|
+
"- Item 1 \n- Item 2 ",
|
|
86
|
+
);
|
|
87
|
+
expect(runPipeline("1. First 🥇\n2. Second 🥈", cfg)).toBe(
|
|
88
|
+
"1. First \n2. Second ",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("strips emojis from markdown blockquotes", () => {
|
|
93
|
+
expect(runPipeline("> This is a quote 💭", cfg)).toBe("> This is a quote ");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("strips emojis from markdown inline code", () => {
|
|
97
|
+
expect(runPipeline("Run `npm install 📦`", cfg)).toBe("Run `npm install `");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("strips emojis from markdown code blocks", () => {
|
|
101
|
+
const input = "```javascript\nconsole.log('Success ✅');\n```";
|
|
102
|
+
const expected = "```javascript\nconsole.log('Success ');\n```";
|
|
103
|
+
expect(runPipeline(input, cfg)).toBe(expected);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("strips emojis from markdown links and images", () => {
|
|
107
|
+
expect(runPipeline("[Click here 🔗](https://example.com)", cfg)).toBe(
|
|
108
|
+
"[Click here ](https://example.com)",
|
|
109
|
+
);
|
|
110
|
+
expect(runPipeline("", cfg)).toBe(
|
|
111
|
+
"",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("strips emojis from markdown tables", () => {
|
|
116
|
+
const input = "| Col 1 🍎 | Col 2 🍌 |\n|---|---|\n| Val 1 🟢 | Val 2 🔴 |";
|
|
117
|
+
const expected = "| Col 1 | Col 2 |\n| ----- | ----- |\n| Val 1 | Val 2 |";
|
|
118
|
+
expect(runPipeline(input, cfg)).toBe(expected);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("flushes table when followed by non-table text", () => {
|
|
122
|
+
const input = "| Col |\n|---|\n| Val |\n\nNormal text";
|
|
123
|
+
const expected = "| Col |\n| --- |\n| Val |\n\nNormal text";
|
|
124
|
+
expect(runPipeline(input, cfg)).toBe(expected);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("strips emojis inside HTML tags within markdown", () => {
|
|
128
|
+
expect(runPipeline('<div title="Awesome 👍">Content 💯</div>', cfg)).toBe(
|
|
129
|
+
'<div title="Awesome ">Content </div>',
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("does NOT strip emojis when stripEmojis is false", () => {
|
|
134
|
+
expect(runPipeline("Hello 👋 World", cfgOff)).toBe("Hello 👋 World");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
mock,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import fsp from "node:fs/promises";
|
|
12
|
+
import { DEFAULT_CONFIG } from "../src/config";
|
|
13
|
+
import { OpenCodeFmt, type PluginInput } from "../src/index";
|
|
14
|
+
|
|
15
|
+
describe("Plugin Entry (index.ts)", () => {
|
|
16
|
+
const mockInput = {
|
|
17
|
+
directory: "/fake/dir",
|
|
18
|
+
client: {} as unknown,
|
|
19
|
+
project: {} as unknown,
|
|
20
|
+
worktree: "",
|
|
21
|
+
serverUrl: new URL("http://localhost"),
|
|
22
|
+
$: {} as unknown,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type TextCompleteHook = (
|
|
26
|
+
arg0: unknown,
|
|
27
|
+
arg1: { text: string },
|
|
28
|
+
) => Promise<void> | void;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
spyOn(fsp, "appendFile").mockResolvedValue(undefined as undefined);
|
|
32
|
+
spyOn(fs, "existsSync");
|
|
33
|
+
spyOn(fs, "readFileSync");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
mock.restore();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("initializes successfully with a valid config", async () => {
|
|
41
|
+
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
42
|
+
spyOn(fs, "readFileSync").mockReturnValue(
|
|
43
|
+
JSON.stringify({ ...DEFAULT_CONFIG, logging: true }),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const hooks = await OpenCodeFmt(mockInput as PluginInput);
|
|
47
|
+
|
|
48
|
+
expect(hooks).toBeDefined();
|
|
49
|
+
expect(typeof hooks["experimental.text.complete"]).toBe("function");
|
|
50
|
+
expect(fsp.appendFile).toHaveBeenCalledWith(
|
|
51
|
+
expect.stringContaining("opencode-fmt.log"),
|
|
52
|
+
expect.stringContaining("Plugin Started"),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("throws error when initializing with missing config", async () => {
|
|
57
|
+
spyOn(fs, "existsSync").mockReturnValue(false);
|
|
58
|
+
|
|
59
|
+
await expect(OpenCodeFmt(mockInput as PluginInput)).rejects.toThrow(
|
|
60
|
+
/Configuration file "config.json" is missing/,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(fsp.appendFile).toHaveBeenCalledWith(
|
|
64
|
+
expect.stringContaining("opencode-fmt.log"),
|
|
65
|
+
expect.stringContaining("FATAL STARTUP ERROR"),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("hook processes text and strips emojis", async () => {
|
|
70
|
+
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
71
|
+
spyOn(fs, "readFileSync").mockReturnValue(
|
|
72
|
+
JSON.stringify({ stripEmojis: true, logging: true }),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const hooks = await OpenCodeFmt(mockInput as PluginInput);
|
|
76
|
+
const textHook = hooks["experimental.text.complete"] as TextCompleteHook;
|
|
77
|
+
|
|
78
|
+
const hookOutput = { text: "Hello 🚀 World" };
|
|
79
|
+
|
|
80
|
+
await textHook({}, hookOutput);
|
|
81
|
+
|
|
82
|
+
expect(hookOutput.text).toBe("Hello World");
|
|
83
|
+
expect(fsp.appendFile).toHaveBeenCalledWith(
|
|
84
|
+
expect.stringContaining("opencode-fmt.log"),
|
|
85
|
+
expect.stringContaining("| Duration: "),
|
|
86
|
+
);
|
|
87
|
+
expect(fsp.appendFile).toHaveBeenCalledWith(
|
|
88
|
+
expect.stringContaining("opencode-fmt.log"),
|
|
89
|
+
expect.stringContaining(
|
|
90
|
+
'Raw: "Hello 🚀 World" | Cleaned: "Hello World"',
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("hook processes clean text without emitting 'Cleaned' log", async () => {
|
|
96
|
+
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
97
|
+
spyOn(fs, "readFileSync").mockReturnValue(
|
|
98
|
+
JSON.stringify({ stripEmojis: true, logging: true }),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const hooks = await OpenCodeFmt(mockInput as PluginInput);
|
|
102
|
+
const textHook = hooks["experimental.text.complete"] as TextCompleteHook;
|
|
103
|
+
|
|
104
|
+
const hookOutput = { text: "No emojis here" };
|
|
105
|
+
|
|
106
|
+
await textHook({}, hookOutput);
|
|
107
|
+
|
|
108
|
+
expect(hookOutput.text).toBe("No emojis here");
|
|
109
|
+
expect(fsp.appendFile).toHaveBeenCalledWith(
|
|
110
|
+
expect.stringContaining("opencode-fmt.log"),
|
|
111
|
+
expect.stringContaining('Response: "No emojis here"'),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("does not log anything when logging is false", async () => {
|
|
116
|
+
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
117
|
+
spyOn(fs, "readFileSync").mockReturnValue(
|
|
118
|
+
JSON.stringify({ logging: false }),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const hooks = await OpenCodeFmt(mockInput as PluginInput);
|
|
122
|
+
const appendSpy = fsp.appendFile as { mockClear: () => void };
|
|
123
|
+
appendSpy.mockClear();
|
|
124
|
+
|
|
125
|
+
const textHook = hooks["experimental.text.complete"] as TextCompleteHook;
|
|
126
|
+
await textHook({}, { text: "Hello" });
|
|
127
|
+
|
|
128
|
+
expect(fsp.appendFile).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("hook logs performance metrics when logging is true", async () => {
|
|
132
|
+
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
133
|
+
spyOn(fs, "readFileSync").mockReturnValue(
|
|
134
|
+
JSON.stringify({ logging: true }),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const hooks = await OpenCodeFmt(mockInput as PluginInput);
|
|
138
|
+
const textHook = hooks["experimental.text.complete"] as TextCompleteHook;
|
|
139
|
+
|
|
140
|
+
await textHook({}, { text: "Hello" });
|
|
141
|
+
|
|
142
|
+
expect(fsp.appendFile).toHaveBeenCalledWith(
|
|
143
|
+
expect.stringContaining("opencode-fmt.log"),
|
|
144
|
+
expect.stringContaining("Duration:"),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("handles error during appendFile gracefully", async () => {
|
|
149
|
+
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
150
|
+
spyOn(fs, "readFileSync").mockReturnValue(
|
|
151
|
+
JSON.stringify({ logging: true }),
|
|
152
|
+
);
|
|
153
|
+
(
|
|
154
|
+
fsp.appendFile as { mockRejectedValueOnce: (err: Error) => void }
|
|
155
|
+
).mockRejectedValueOnce(new Error("Disk Full"));
|
|
156
|
+
|
|
157
|
+
const hooks = await OpenCodeFmt(mockInput as PluginInput);
|
|
158
|
+
const textHook = hooks["experimental.text.complete"] as TextCompleteHook;
|
|
159
|
+
|
|
160
|
+
const hookOutput = { text: "Hello" };
|
|
161
|
+
await textHook({}, hookOutput);
|
|
162
|
+
|
|
163
|
+
expect(hookOutput.text).toBe("Hello");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("trigger FATAL STARTUP ERROR path in OpenCodeFmt", async () => {
|
|
167
|
+
spyOn(fs, "existsSync").mockReturnValue(true);
|
|
168
|
+
spyOn(fs, "readFileSync").mockImplementation(() => {
|
|
169
|
+
throw new Error("Critical FS Error");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await expect(OpenCodeFmt(mockInput as PluginInput)).rejects.toThrow(
|
|
173
|
+
/\[opencode-fmt\] Failed to parse config\.json: Critical FS Error/,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
expect(fsp.appendFile).toHaveBeenCalledWith(
|
|
177
|
+
expect.stringContaining("opencode-fmt.log"),
|
|
178
|
+
expect.stringContaining(
|
|
179
|
+
"FATAL STARTUP ERROR: [opencode-fmt] Failed to parse config.json: Critical FS Error",
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"allowJs": true,
|
|
12
|
+
"esModuleInterop": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|