bonecode 1.3.0 → 1.4.2

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.
Files changed (32) hide show
  1. package/README.md +42 -0
  2. package/compat/opencode_adapter.ts +69 -8
  3. package/dist/compat/opencode_adapter.js +63 -7
  4. package/dist/compat/opencode_adapter.js.map +1 -1
  5. package/dist/src/db_adapter.js +30 -0
  6. package/dist/src/db_adapter.js.map +1 -1
  7. package/dist/src/engine/session/build_mode.d.ts +83 -0
  8. package/dist/src/engine/session/build_mode.js +800 -0
  9. package/dist/src/engine/session/build_mode.js.map +1 -0
  10. package/dist/src/engine/session/build_mode_helpers.d.ts +6 -0
  11. package/dist/src/engine/session/build_mode_helpers.js +61 -0
  12. package/dist/src/engine/session/build_mode_helpers.js.map +1 -0
  13. package/dist/src/engine/session/leaked_tool_call.d.ts +49 -0
  14. package/dist/src/engine/session/leaked_tool_call.js +174 -0
  15. package/dist/src/engine/session/leaked_tool_call.js.map +1 -0
  16. package/dist/src/engine/session/prompt/bonescript.txt +11 -0
  17. package/dist/src/engine/session/prompt.js +173 -2
  18. package/dist/src/engine/session/prompt.js.map +1 -1
  19. package/dist/src/tui.js +146 -9
  20. package/dist/src/tui.js.map +1 -1
  21. package/package.json +1 -1
  22. package/scripts/debug_extract.js +40 -0
  23. package/scripts/test_build_fallback.js +221 -0
  24. package/scripts/test_build_mode.js +301 -0
  25. package/scripts/test_leaked_tool_call.js +269 -0
  26. package/src/db_adapter.ts +29 -0
  27. package/src/engine/session/build_mode.ts +906 -0
  28. package/src/engine/session/build_mode_helpers.ts +72 -0
  29. package/src/engine/session/leaked_tool_call.ts +166 -0
  30. package/src/engine/session/prompt/bonescript.txt +11 -0
  31. package/src/engine/session/prompt.ts +219 -2
  32. package/src/tui.ts +147 -9
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Tests for the JSON-manifest fallback applier.
4
+ *
5
+ * The model-call portion is mocked — we feed in synthetic manifests and verify
6
+ * the on-disk result matches. The path-safety checks are critical to test
7
+ * because a buggy fallback could let a model write outside the worktree.
8
+ */
9
+
10
+ "use strict";
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const os = require("os");
14
+
15
+ const G = "\x1b[32m"; const R = "\x1b[31m"; const C = "\x1b[36m";
16
+ const B = "\x1b[1m"; const D = "\x1b[2m"; const N = "\x1b[0m";
17
+
18
+ let passed = 0;
19
+ let failed = 0;
20
+ const failures = [];
21
+
22
+ function ok(name, info = "") {
23
+ passed++;
24
+ console.log(` ${G}✓${N} ${name}${info ? ` ${D}${info}${N}` : ""}`);
25
+ }
26
+ function fail(name, msg) {
27
+ failed++;
28
+ failures.push(`${name}: ${msg}`);
29
+ console.log(` ${R}✗${N} ${name} ${R}${msg}${N}`);
30
+ }
31
+ function header(s) { console.log(`\n${C}${B}${s}${N}`); }
32
+
33
+ // We re-implement the path-safety logic from executeFallback as a pure function
34
+ // so we can test it without spinning up the DB.
35
+ function applyManifest(manifest, worktree) {
36
+ const errors = [];
37
+ const written = [];
38
+
39
+ for (const f of manifest.files || []) {
40
+ if (!f || typeof f.path !== "string" || typeof f.content !== "string") continue;
41
+ if (f.path.includes("..") || path.isAbsolute(f.path)) {
42
+ errors.push(`refused unsafe path: ${f.path}`);
43
+ continue;
44
+ }
45
+ const target = path.resolve(worktree, f.path);
46
+ if (!target.startsWith(path.resolve(worktree))) {
47
+ errors.push(`refused path outside worktree: ${f.path}`);
48
+ continue;
49
+ }
50
+ try {
51
+ fs.mkdirSync(path.dirname(target), { recursive: true });
52
+ fs.writeFileSync(target, f.content, "utf-8");
53
+ written.push(target);
54
+ } catch (e) {
55
+ errors.push(`${f.path}: ${e.message}`);
56
+ }
57
+ }
58
+ return { written, errors };
59
+ }
60
+
61
+ // ─── Test setup ──────────────────────────────────────────────────────────────
62
+
63
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "bonecode-fallback-test-"));
64
+
65
+ function cleanup() {
66
+ try {
67
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
68
+ } catch {}
69
+ }
70
+ process.on("exit", cleanup);
71
+ process.on("SIGINT", () => { cleanup(); process.exit(130); });
72
+
73
+ // ─── Tests ───────────────────────────────────────────────────────────────────
74
+
75
+ header("[1] Manifest applies files correctly");
76
+
77
+ (() => {
78
+ const wt = fs.mkdtempSync(path.join(tmpRoot, "wt-"));
79
+ const r = applyManifest({
80
+ files: [
81
+ { path: "hello.txt", content: "hello world" },
82
+ { path: "src/main.ts", content: "export const x = 42;\n" },
83
+ ],
84
+ }, wt);
85
+
86
+ if (r.written.length === 2 && r.errors.length === 0) {
87
+ ok("two files written");
88
+ } else {
89
+ fail("two files", `written=${r.written.length} errors=${r.errors.join(", ")}`);
90
+ }
91
+
92
+ if (fs.readFileSync(path.join(wt, "hello.txt"), "utf-8") === "hello world") ok("file content correct (root)");
93
+ else fail("root content", "mismatch");
94
+
95
+ if (fs.readFileSync(path.join(wt, "src/main.ts"), "utf-8") === "export const x = 42;\n") ok("file content correct (nested)");
96
+ else fail("nested content", "mismatch");
97
+
98
+ if (fs.statSync(path.join(wt, "src")).isDirectory()) ok("nested directory created");
99
+ else fail("nested dir", "missing");
100
+ })();
101
+
102
+ header("[2] Path-traversal rejected");
103
+
104
+ (() => {
105
+ const wt = fs.mkdtempSync(path.join(tmpRoot, "wt-"));
106
+ const r = applyManifest({
107
+ files: [
108
+ { path: "../escape.txt", content: "should not write" },
109
+ { path: "ok.txt", content: "fine" },
110
+ ],
111
+ }, wt);
112
+
113
+ if (r.written.length === 1 && r.errors.length === 1 && /unsafe/.test(r.errors[0])) {
114
+ ok("traversal rejected, safe file written");
115
+ } else {
116
+ fail("traversal", `written=${r.written.length} errors=${r.errors.join(", ")}`);
117
+ }
118
+
119
+ // Verify the parent dir was NOT touched
120
+ const parent = path.dirname(wt);
121
+ if (!fs.existsSync(path.join(parent, "escape.txt"))) ok("no file written outside worktree");
122
+ else fail("escape", "file leaked outside worktree");
123
+ })();
124
+
125
+ header("[3] Absolute path rejected");
126
+
127
+ (() => {
128
+ const wt = fs.mkdtempSync(path.join(tmpRoot, "wt-"));
129
+ const absolute = process.platform === "win32" ? "C:\\evil.txt" : "/tmp/evil.txt";
130
+ const r = applyManifest({
131
+ files: [{ path: absolute, content: "should not write" }],
132
+ }, wt);
133
+
134
+ if (r.written.length === 0 && r.errors.length === 1 && /unsafe/.test(r.errors[0])) {
135
+ ok("absolute path rejected");
136
+ } else {
137
+ fail("absolute", `written=${r.written.length} errors=${r.errors.join(", ")}`);
138
+ }
139
+ })();
140
+
141
+ header("[4] Malformed entries skipped silently");
142
+
143
+ (() => {
144
+ const wt = fs.mkdtempSync(path.join(tmpRoot, "wt-"));
145
+ const r = applyManifest({
146
+ files: [
147
+ null,
148
+ undefined,
149
+ {},
150
+ { path: "ok.txt", content: "fine" },
151
+ { path: 123, content: "not a string" },
152
+ { path: "no-content.txt" },
153
+ ],
154
+ }, wt);
155
+
156
+ if (r.written.length === 1) ok("only well-formed entry processed");
157
+ else fail("malformed", `wrote ${r.written.length} files (expected 1)`);
158
+ })();
159
+
160
+ header("[5] Empty manifest is a no-op");
161
+
162
+ (() => {
163
+ const wt = fs.mkdtempSync(path.join(tmpRoot, "wt-"));
164
+ const r1 = applyManifest({}, wt);
165
+ const r2 = applyManifest({ files: [] }, wt);
166
+ const r3 = applyManifest({ files: [], commands: [] }, wt);
167
+
168
+ if (r1.written.length === 0 && r2.written.length === 0 && r3.written.length === 0) {
169
+ ok("all empty shapes produce zero writes");
170
+ } else {
171
+ fail("empty", "writes happened on empty manifest");
172
+ }
173
+ })();
174
+
175
+ header("[6] Overwriting existing files works");
176
+
177
+ (() => {
178
+ const wt = fs.mkdtempSync(path.join(tmpRoot, "wt-"));
179
+ const target = path.join(wt, "f.txt");
180
+ fs.writeFileSync(target, "old content");
181
+
182
+ const r = applyManifest({
183
+ files: [{ path: "f.txt", content: "new content" }],
184
+ }, wt);
185
+
186
+ if (r.written.length === 1 && fs.readFileSync(target, "utf-8") === "new content") {
187
+ ok("overwrite works");
188
+ } else {
189
+ fail("overwrite", `written=${r.written.length} content="${fs.readFileSync(target, "utf-8")}"`);
190
+ }
191
+ })();
192
+
193
+ header("[7] BuildState includes tool_capable field");
194
+
195
+ (() => {
196
+ const src = fs.readFileSync(path.resolve(__dirname, "..", "src", "engine", "session", "build_mode.ts"), "utf-8");
197
+ if (/tool_capable\??\s*:\s*boolean/.test(src)) ok("BuildState declares tool_capable: boolean");
198
+ else fail("interface", "tool_capable not declared on BuildState");
199
+
200
+ if (/probeToolCapability/.test(src)) ok("probeToolCapability function exists");
201
+ else fail("probe", "function missing");
202
+
203
+ if (/executeFallback/.test(src)) ok("executeFallback function exists");
204
+ else fail("fallback", "function missing");
205
+
206
+ if (/MODEL_SUPPORTS_TOOLS/.test(src)) ok("respects MODEL_SUPPORTS_TOOLS env override");
207
+ else fail("env override", "not honored");
208
+
209
+ if (/ABORT_AFTER_CONSECUTIVE_ZERO/.test(src)) ok("early-bailout threshold present");
210
+ else fail("bailout", "no early bailout logic");
211
+ })();
212
+
213
+ console.log();
214
+ if (failed === 0) {
215
+ console.log(`${G}${B}✓ All ${passed} tests passed${N}`);
216
+ process.exit(0);
217
+ } else {
218
+ console.log(`${R}${B}✗ ${failed} failed, ${passed} passed${N}`);
219
+ for (const f of failures) console.log(` ${R}- ${f}${N}`);
220
+ process.exit(1);
221
+ }
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Unit tests for build_mode.ts pure logic:
4
+ * - parseJsonLoose handles fenced/unfenced/think-tagged/balanced JSON
5
+ * - isBuildPrompt identifies project-scoped vs ad-hoc prompts
6
+ * - state transitions are persistable / restorable
7
+ *
8
+ * Skips anything that requires the live language model (askJson, runBuildMode).
9
+ */
10
+
11
+ "use strict";
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ const G = "\x1b[32m"; const R = "\x1b[31m"; const C = "\x1b[36m";
16
+ const B = "\x1b[1m"; const D = "\x1b[2m"; const N = "\x1b[0m";
17
+
18
+ let passed = 0;
19
+ let failed = 0;
20
+ const failures = [];
21
+
22
+ function ok(name, info = "") {
23
+ passed++;
24
+ console.log(` ${G}✓${N} ${name}${info ? ` ${D}${info}${N}` : ""}`);
25
+ }
26
+ function fail(name, msg) {
27
+ failed++;
28
+ failures.push(`${name}: ${msg}`);
29
+ console.log(` ${R}✗${N} ${name} ${R}${msg}${N}`);
30
+ }
31
+ function header(s) { console.log(`\n${C}${B}${s}${N}`); }
32
+
33
+ // Extract pure functions from build_mode.ts source (so we don't trigger imports
34
+ // that need a live DB pool / network)
35
+ const ROOT = path.resolve(__dirname, "..");
36
+ const src = fs.readFileSync(path.join(ROOT, "src", "engine", "session", "build_mode.ts"), "utf-8");
37
+
38
+ function extractFunc(name) {
39
+ // Match `function name(`, `export function name(`, or with generic params <T>
40
+ const re = new RegExp(`(?:export\\s+)?function ${name}(?:<[^>]+>)?\\s*\\(`);
41
+ const m = src.match(re);
42
+ if (!m) return null;
43
+ // Strip the leading "export " if present so the eval'd source defines a local function
44
+ const start = m.index + (m[0].startsWith("export") ? "export ".length : 0);
45
+ let depth = 0;
46
+ let inStr = false;
47
+ let strChar = "";
48
+ let inTpl = false;
49
+ let inLineComment = false;
50
+ let inBlockComment = false;
51
+ let escape = false;
52
+ let i = src.indexOf("{", start);
53
+ for (; i < src.length; i++) {
54
+ const ch = src[i];
55
+ const next = src[i + 1];
56
+ if (escape) { escape = false; continue; }
57
+ if (ch === "\\") { escape = true; continue; }
58
+ if (inLineComment) {
59
+ if (ch === "\n") inLineComment = false;
60
+ continue;
61
+ }
62
+ if (inBlockComment) {
63
+ if (ch === "*" && next === "/") { inBlockComment = false; i++; }
64
+ continue;
65
+ }
66
+ if (inStr) {
67
+ if (ch === strChar) inStr = false;
68
+ continue;
69
+ }
70
+ if (inTpl) {
71
+ if (ch === "`") inTpl = false;
72
+ continue;
73
+ }
74
+ if (ch === "/" && next === "/") { inLineComment = true; i++; continue; }
75
+ if (ch === "/" && next === "*") { inBlockComment = true; i++; continue; }
76
+ if (ch === '"' || ch === "'") { inStr = true; strChar = ch; continue; }
77
+ if (ch === "`") { inTpl = true; continue; }
78
+ if (ch === "{") depth++;
79
+ else if (ch === "}") { depth--; if (depth === 0) return src.slice(start, i + 1); }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ const parseJsonLooseSrc = extractFunc("parseJsonLoose");
85
+ const extractBalancedSrc = extractFunc("extractBalanced");
86
+ const tryParseSrc = extractFunc("tryParse");
87
+ const isBuildPromptSrc = extractFunc("isBuildPrompt");
88
+
89
+ if (!parseJsonLooseSrc || !extractBalancedSrc || !tryParseSrc || !isBuildPromptSrc) {
90
+ console.error("Could not extract helpers from build_mode.ts");
91
+ process.exit(1);
92
+ }
93
+
94
+ // Strip TypeScript annotations for plain Node eval
95
+ function stripTs(s) {
96
+ return s
97
+ // Remove generic type parameters on function declarations: `function foo<T>(`
98
+ .replace(/function\s+(\w+)\s*<[^>]+>/g, "function $1")
99
+ // Remove generic type args at call sites: `foo<Bar>(...)` → `foo(...)`
100
+ // Only strip when followed by `(` so we don't break comparisons.
101
+ .replace(/(\b[a-zA-Z_$][\w$]*)<[^<>=]+>(?=\s*\()/g, "$1")
102
+ // Remove return-type annotations like `): T | null {` or `): string {`
103
+ .replace(/\)\s*:\s*[^=({}]+\s*\{/g, ") {")
104
+ // Remove parameter type annotations: `name: type`
105
+ .replace(/(\(|,\s*)([a-zA-Z_$][\w$]*)\s*:\s*[a-zA-Z_$][\w$<>|&,\s\[\]'"]*?(?=[,)])/g, "$1$2")
106
+ // Remove "as Type" assertions
107
+ .replace(/\bas\s+\w+(?:\s*\|\s*\w+)*/g, "")
108
+ // Drop "export " on declarations
109
+ .replace(/^export\s+/gm, "");
110
+ }
111
+
112
+ const sandboxSrc = `
113
+ ${stripTs(extractBalancedSrc)}
114
+ ${stripTs(tryParseSrc)}
115
+ ${stripTs(parseJsonLooseSrc)}
116
+ ${stripTs(isBuildPromptSrc)}
117
+ return { parseJsonLoose, extractBalanced, tryParse, isBuildPrompt };
118
+ `;
119
+
120
+ let helpers;
121
+ try {
122
+ helpers = new Function(sandboxSrc)();
123
+ } catch (e) {
124
+ console.error("Failed to evaluate helpers:", e.message);
125
+ console.error(sandboxSrc.slice(0, 500));
126
+ process.exit(1);
127
+ }
128
+
129
+ const { parseJsonLoose, isBuildPrompt } = helpers;
130
+
131
+ // ─── parseJsonLoose ───────────────────────────────────────────────────────────
132
+
133
+ header("[1] parseJsonLoose — JSON extraction");
134
+
135
+ (() => {
136
+ const r = parseJsonLoose('{"a": 1, "b": "two"}');
137
+ if (r && r.a === 1 && r.b === "two") ok("plain JSON object");
138
+ else fail("plain JSON", JSON.stringify(r));
139
+ })();
140
+
141
+ (() => {
142
+ const r = parseJsonLoose('```json\n{"x": 42}\n```');
143
+ if (r && r.x === 42) ok("fenced JSON");
144
+ else fail("fenced JSON", JSON.stringify(r));
145
+ })();
146
+
147
+ (() => {
148
+ const r = parseJsonLoose("```\n{\"x\": 42}\n```");
149
+ if (r && r.x === 42) ok("fenced JSON without language");
150
+ else fail("fenced no-lang", JSON.stringify(r));
151
+ })();
152
+
153
+ (() => {
154
+ const r = parseJsonLoose('Here is the answer: {"ok": true} done.');
155
+ if (r && r.ok === true) ok("JSON with surrounding prose");
156
+ else fail("prose+json", JSON.stringify(r));
157
+ })();
158
+
159
+ (() => {
160
+ const r = parseJsonLoose('<think>let me think</think>\n{"ready": true}');
161
+ if (r && r.ready === true) ok("JSON after <think> block");
162
+ else fail("think+json", JSON.stringify(r));
163
+ })();
164
+
165
+ (() => {
166
+ // Nested objects
167
+ const r = parseJsonLoose('{"design": {"goal": "build a thing", "requirements": ["a", "b"]}}');
168
+ if (r && r.design && r.design.goal === "build a thing" && r.design.requirements.length === 2) {
169
+ ok("nested object with array");
170
+ } else {
171
+ fail("nested object", JSON.stringify(r));
172
+ }
173
+ })();
174
+
175
+ (() => {
176
+ const r = parseJsonLoose("not json at all");
177
+ if (r === null) ok("non-JSON returns null");
178
+ else fail("non-JSON", JSON.stringify(r));
179
+ })();
180
+
181
+ (() => {
182
+ // String containing braces
183
+ const r = parseJsonLoose('{"q": "what about { this }?"}');
184
+ if (r && r.q === "what about { this }?") ok("string with braces inside");
185
+ else fail("string with braces", JSON.stringify(r));
186
+ })();
187
+
188
+ (() => {
189
+ // JSON array at top level
190
+ const r = parseJsonLoose("[1, 2, 3]");
191
+ if (Array.isArray(r) && r.length === 3) ok("top-level array");
192
+ else fail("array", JSON.stringify(r));
193
+ })();
194
+
195
+ (() => {
196
+ // Truncated JSON returns null
197
+ const r = parseJsonLoose('{"a": 1, "b":');
198
+ if (r === null) ok("truncated JSON returns null");
199
+ else fail("truncated", JSON.stringify(r));
200
+ })();
201
+
202
+ // ─── isBuildPrompt ────────────────────────────────────────────────────────────
203
+
204
+ header("[2] isBuildPrompt — trigger detection");
205
+
206
+ const buildPrompts = [
207
+ "build me a 2D market simulation",
208
+ "create a full e-commerce backend",
209
+ "design and implement a chat application",
210
+ "make a complete authentication system",
211
+ "implement a full GraphQL API",
212
+ "build a backend for a todo app",
213
+ "create a marketplace from scratch",
214
+ "build me an end-to-end notification service",
215
+ ];
216
+ for (const p of buildPrompts) {
217
+ if (isBuildPrompt(p)) ok(`"${p.slice(0, 50)}..."`, "→ build");
218
+ else fail(`build prompt missed`, p);
219
+ }
220
+
221
+ const adhocPrompts = [
222
+ "what does this function do",
223
+ "fix the typo in line 5",
224
+ "explain this error",
225
+ "rename foo to bar",
226
+ "show me the file",
227
+ "hi",
228
+ "how do I run this",
229
+ ];
230
+ for (const p of adhocPrompts) {
231
+ if (!isBuildPrompt(p)) ok(`"${p}" → not build`);
232
+ else fail(`adhoc prompt over-matched`, p);
233
+ }
234
+
235
+ // ─── State persistence shape ──────────────────────────────────────────────────
236
+
237
+ header("[3] BuildState shape — JSON round-trip");
238
+
239
+ (() => {
240
+ const state = {
241
+ stage: "execute",
242
+ original_prompt: "build a thing",
243
+ design: {
244
+ goal: "thing",
245
+ requirements: ["does X", "does Y"],
246
+ constraints: ["fast", "simple"],
247
+ artifacts: ["src/thing.ts"],
248
+ },
249
+ todos: [
250
+ { id: "abc", title: "Write src/thing.ts", description: "create the thing", status: "completed", failure_count: 0, evidence: "1 tool call" },
251
+ { id: "def", title: "Test it", description: "run tests", status: "pending", failure_count: 1 },
252
+ ],
253
+ iteration: 5,
254
+ max_iterations: 30,
255
+ };
256
+ const round = JSON.parse(JSON.stringify(state));
257
+ const equal = JSON.stringify(round) === JSON.stringify(state);
258
+ if (equal) ok("BuildState round-trips through JSON");
259
+ else fail("round-trip", "objects differ");
260
+ })();
261
+
262
+ // ─── extractBalanced edge cases ───────────────────────────────────────────────
263
+
264
+ header("[4] extractBalanced — bracket matching");
265
+
266
+ const { extractBalanced } = helpers;
267
+
268
+ (() => {
269
+ const r = extractBalanced("xx{a: 1}xx", 2, "{", "}");
270
+ if (r === "{a: 1}") ok("simple braces");
271
+ else fail("simple braces", `got "${r}"`);
272
+ })();
273
+
274
+ (() => {
275
+ const r = extractBalanced('{"q": "with { brace"}', 0, "{", "}");
276
+ if (r === '{"q": "with { brace"}') ok("braces inside string ignored");
277
+ else fail("string brace", `got "${r}"`);
278
+ })();
279
+
280
+ (() => {
281
+ const r = extractBalanced("{a:{b:{c:1}}}", 0, "{", "}");
282
+ if (r === "{a:{b:{c:1}}}") ok("nested braces");
283
+ else fail("nested", `got "${r}"`);
284
+ })();
285
+
286
+ (() => {
287
+ // Unbalanced — returns what we have
288
+ const r = extractBalanced("{a:1", 0, "{", "}");
289
+ if (r === "{a:1") ok("unbalanced returns partial");
290
+ else fail("unbalanced", `got "${r}"`);
291
+ })();
292
+
293
+ console.log();
294
+ if (failed === 0) {
295
+ console.log(`${G}${B}✓ All ${passed} tests passed${N}`);
296
+ process.exit(0);
297
+ } else {
298
+ console.log(`${R}${B}✗ ${failed} failed, ${passed} passed${N}`);
299
+ for (const f of failures) console.log(` ${R}- ${f}${N}`);
300
+ process.exit(1);
301
+ }