@openparachute/vault 0.6.5-rc.2 → 0.6.5
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/.parachute/module.json +1 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/do-param-cap.test.ts +161 -0
- package/core/src/links.ts +8 -13
- package/core/src/mcp.ts +1 -1
- package/core/src/notes.ts +33 -15
- package/core/src/onboarding.ts +14 -296
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +357 -0
- package/core/src/seed-packs.ts +823 -0
- package/core/src/sql-in.test.ts +58 -0
- package/core/src/sql-in.ts +76 -0
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/transcription/provider.ts +141 -0
- package/core/src/types.ts +1 -1
- package/core/src/vault-projection.ts +1 -1
- package/core/src/wikilinks.ts +10 -4
- package/package.json +1 -1
- package/src/add-pack.test.ts +142 -0
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +806 -7
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/onboarding-seed.test.ts +188 -40
- package/src/onboarding-seed.ts +41 -46
- package/src/routes.ts +20 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +18 -6
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +133 -31
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +305 -0
- package/src/transcription/build.ts +332 -0
- package/src/transcription/capability.test.ts +118 -0
- package/src/transcription/capability.ts +96 -0
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/install.test.ts +167 -0
- package/src/transcription/install.ts +296 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/providers/scribe-http.test.ts +195 -0
- package/src/transcription/providers/scribe-http.ts +144 -0
- package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
- package/src/transcription/providers/transcribe-cpp.ts +293 -0
- package/src/transcription/select.test.ts +299 -0
- package/src/transcription/select.ts +397 -0
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/transcription-worker.test.ts +44 -0
- package/src/transcription-worker.ts +57 -122
- package/src/vault-create.test.ts +43 -10
- package/src/vault.test.ts +49 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
package/src/tag-scope.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tag-scope enforcement for tag-scoped tokens (
|
|
2
|
+
* Tag-scope enforcement for tag-scoped tokens (docs/contracts/tag-scoped-tokens.md).
|
|
3
3
|
*
|
|
4
4
|
* A token's `scoped_tags` allowlist narrows its effective access to notes
|
|
5
5
|
* carrying one of the allowlisted tags or a sub-tag thereof. The expansion
|
|
6
6
|
* to descendants happens via the per-vault `_tags/<name>` config-note
|
|
7
7
|
* hierarchy (see core/src/tag-hierarchy.ts).
|
|
8
8
|
*
|
|
9
|
-
* Auth check pseudocode (from
|
|
9
|
+
* Auth check pseudocode (from docs/contracts/tag-scoped-tokens.md):
|
|
10
10
|
*
|
|
11
11
|
* if (!hasScope(token, ...)) return forbidden();
|
|
12
12
|
* if (token.scoped_tags === null) return ok(); // unscoped
|
|
@@ -38,7 +38,7 @@ export async function expandTokenTagScope(
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Return true iff the note's tag set intersects the expanded allowlist OR
|
|
41
|
-
* — fail-open per
|
|
41
|
+
* — fail-open per docs/contracts/tag-scoped-tokens.md §Storage — any of the
|
|
42
42
|
* note's tags has a string-form root inside `rawRoots`. The string-form
|
|
43
43
|
* fallback covers the orphan-sub-tag case: a token allowlisted for
|
|
44
44
|
* `health` should still see `#health/food` even when no `_tags/health/food`
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared live-query frame corpus — MIRRORED VERBATIM from the cloud door's
|
|
3
|
+
* `parachute-cloud/workers/vault/test/fixtures/live-frame-corpus.ts` (the SINGLE
|
|
4
|
+
* source of truth). Kept byte-identical here so the self-host WS/SSE parity test
|
|
5
|
+
* asserts against the EXACT same fixture the cloud door pins — the load-bearing
|
|
6
|
+
* cross-door conformance check (WS-hibernation migration, 2026-07-04). If the
|
|
7
|
+
* canonical corpus changes upstream, re-mirror this file.
|
|
8
|
+
*
|
|
9
|
+
* Each case is a transport-neutral `(event, data)` tuple. The invariant under
|
|
10
|
+
* test: for a given case, the SSE `data:` body and the WS message carry a
|
|
11
|
+
* byte-IDENTICAL serialization of the inner payload (`notes` / `note` / `id`) —
|
|
12
|
+
* the ONLY difference is that the WS message folds the SSE `event:` NAME into a
|
|
13
|
+
* `type` discriminator (`{ type, ...data }`), because a WebSocket message has no
|
|
14
|
+
* separate event-name framing. The snapshot case additionally chunks with a
|
|
15
|
+
* `done` flag on the WS side (transport framing under the ~1 MiB message cap);
|
|
16
|
+
* its note OBJECTS still serialize identically.
|
|
17
|
+
*
|
|
18
|
+
* The notes deliberately include the drift-prone bits: unicode, nested metadata,
|
|
19
|
+
* a null value, an empty array, quotes/newlines in content, and stable key
|
|
20
|
+
* ordering — anything a careless re-serialization would reorder or mangle.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A note-shaped payload (loosely typed — the parity test cares about bytes, not
|
|
24
|
+
* the full Note interface; the wire carries whatever the store returns). */
|
|
25
|
+
export interface CorpusNote {
|
|
26
|
+
id: string;
|
|
27
|
+
path: string;
|
|
28
|
+
content: string;
|
|
29
|
+
tags: string[];
|
|
30
|
+
metadata: Record<string, unknown>;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
extension: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const NOTE_A: CorpusNote = {
|
|
37
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
38
|
+
path: "greetings/héllo",
|
|
39
|
+
content: 'line one\nline "two" with quotes\n#greeting [[world]]',
|
|
40
|
+
tags: ["greeting", "watch"],
|
|
41
|
+
metadata: { mood: "warm", priority: 5, pinned: true, note: null, aliases: [] },
|
|
42
|
+
createdAt: "2026-07-04T00:00:00.000Z",
|
|
43
|
+
updatedAt: "2026-07-04T00:00:01.000Z",
|
|
44
|
+
extension: "md",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const NOTE_B: CorpusNote = {
|
|
48
|
+
id: "22222222-2222-4222-8222-222222222222",
|
|
49
|
+
path: "meetings/2026-07-04",
|
|
50
|
+
content: "standup — ☂️ parachute cloud",
|
|
51
|
+
tags: ["meeting"],
|
|
52
|
+
metadata: { attendees: ["aaron", "uni"], count: 2 },
|
|
53
|
+
createdAt: "2026-07-04T09:00:00.000Z",
|
|
54
|
+
updatedAt: "2026-07-04T09:30:00.000Z",
|
|
55
|
+
extension: "md",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export interface FrameCase {
|
|
59
|
+
name: string;
|
|
60
|
+
event: "snapshot" | "upsert" | "remove";
|
|
61
|
+
/** The inner payload — the SSE `data:` body is exactly `JSON.stringify(data)`;
|
|
62
|
+
* the WS message is `JSON.stringify({ type: event, ...data })`. */
|
|
63
|
+
data: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const FRAME_CORPUS: FrameCase[] = [
|
|
67
|
+
{ name: "snapshot (two notes)", event: "snapshot", data: { notes: [NOTE_A, NOTE_B] } },
|
|
68
|
+
{ name: "snapshot (empty set)", event: "snapshot", data: { notes: [] } },
|
|
69
|
+
{ name: "upsert (rich note)", event: "upsert", data: { note: NOTE_A } },
|
|
70
|
+
{ name: "upsert (unicode note)", event: "upsert", data: { note: NOTE_B } },
|
|
71
|
+
{ name: "remove (id)", event: "remove", data: { id: NOTE_A.id } },
|
|
72
|
+
];
|
package/src/token-store.ts
CHANGED
|
@@ -54,7 +54,7 @@ export interface Token {
|
|
|
54
54
|
* access is the intersection of `scopes` and notes carrying one of these
|
|
55
55
|
* tags or a sub-tag thereof (hierarchy expansion via getTagDescendants).
|
|
56
56
|
* NULL = unscoped, full vault access per `scopes`. See
|
|
57
|
-
*
|
|
57
|
+
* docs/contracts/tag-scoped-tokens.md.
|
|
58
58
|
*/
|
|
59
59
|
scoped_tags: string[] | null;
|
|
60
60
|
/**
|
|
@@ -301,7 +301,7 @@ export function markMcpMintLedgerRevoked(
|
|
|
301
301
|
* Returns display ID + label pairs (no token-hash exposure) so error
|
|
302
302
|
* envelopes can name the offending tokens for the operator. The match is
|
|
303
303
|
* exact on the root name — `scoped_tags` only ever stores roots per
|
|
304
|
-
*
|
|
304
|
+
* docs/contracts/tag-scoped-tokens.md.
|
|
305
305
|
*/
|
|
306
306
|
export function findTokensReferencingTag(
|
|
307
307
|
db: Database,
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import {
|
|
4
|
+
buildTranscribeCli,
|
|
5
|
+
buildCompileCommand,
|
|
6
|
+
detectCompiler,
|
|
7
|
+
compilerInstallHint,
|
|
8
|
+
cliSourceUrl,
|
|
9
|
+
patchMainCppBackendInit,
|
|
10
|
+
BACKEND_INIT_PATCH_ANCHOR,
|
|
11
|
+
BACKEND_INIT_PATCH_MARKER,
|
|
12
|
+
CLI_SOURCE_FILES,
|
|
13
|
+
CXX_CANDIDATES,
|
|
14
|
+
TRANSCRIBE_CPP_SOURCE_REF,
|
|
15
|
+
type BuildCliDeps,
|
|
16
|
+
type CompileResult,
|
|
17
|
+
} from "./build.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* transcribe-cli build tests (scribe-fold Phase 2b). Pure recipe shape +
|
|
21
|
+
* fully-mocked orchestration — NO network, NO real compiler (the real compile
|
|
22
|
+
* is exercised only by the install-time live verify). Covers the toolchain-
|
|
23
|
+
* absent, fetch-failure, patch-failure, compile-failure, and success branches,
|
|
24
|
+
* and the "never leave a half-built binary" invariant.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** A minimal stand-in for the pinned main.cpp: log-sink install + the batch-
|
|
28
|
+
* mode comment the vault#534 patch anchors on. */
|
|
29
|
+
const MAIN_CPP_FIXTURE = [
|
|
30
|
+
"int main(int argc, char ** argv) {",
|
|
31
|
+
" if (!args.quiet) {",
|
|
32
|
+
" transcribe_log_set(log_cb, nullptr);",
|
|
33
|
+
" }",
|
|
34
|
+
"",
|
|
35
|
+
BACKEND_INIT_PATCH_ANCHOR,
|
|
36
|
+
" // the model ONCE and reuses the context across all files.",
|
|
37
|
+
" if (!args.batch_file.empty()) {",
|
|
38
|
+
" }",
|
|
39
|
+
"}",
|
|
40
|
+
"",
|
|
41
|
+
].join("\n");
|
|
42
|
+
|
|
43
|
+
describe("detectCompiler", () => {
|
|
44
|
+
test("returns the first candidate found (c++ preferred)", () => {
|
|
45
|
+
const which = (c: string) => (c === "c++" ? "/usr/bin/c++" : null);
|
|
46
|
+
expect(detectCompiler(which)).toBe("/usr/bin/c++");
|
|
47
|
+
});
|
|
48
|
+
test("falls through to a later candidate when earlier ones are absent", () => {
|
|
49
|
+
const which = (c: string) => (c === "g++" ? "/usr/bin/g++" : null);
|
|
50
|
+
expect(detectCompiler(which)).toBe("/usr/bin/g++");
|
|
51
|
+
});
|
|
52
|
+
test("returns null when NO compiler is on PATH", () => {
|
|
53
|
+
expect(detectCompiler(() => null)).toBeNull();
|
|
54
|
+
expect(detectCompiler(() => undefined)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
test("probes the documented candidate order", () => {
|
|
57
|
+
expect([...CXX_CANDIDATES]).toEqual(["c++", "clang++", "g++"]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("compilerInstallHint", () => {
|
|
62
|
+
test("macOS → xcode-select", () => {
|
|
63
|
+
expect(compilerInstallHint("darwin")).toContain("xcode-select --install");
|
|
64
|
+
});
|
|
65
|
+
test("linux → build-essential / gcc-c++", () => {
|
|
66
|
+
const h = compilerInstallHint("linux");
|
|
67
|
+
expect(h).toContain("build-essential");
|
|
68
|
+
expect(h).toContain("gcc-c++");
|
|
69
|
+
});
|
|
70
|
+
test("other → generic compiler hint", () => {
|
|
71
|
+
expect(compilerInstallHint("win32")).toContain("c++/clang++/g++");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("cliSourceUrl + CLI_SOURCE_FILES", () => {
|
|
76
|
+
test("URL pins the exact v0.1.1 source ref", () => {
|
|
77
|
+
const url = cliSourceUrl("examples/cli/main.cpp");
|
|
78
|
+
expect(url).toContain("raw.githubusercontent.com/handy-computer/transcribe.cpp");
|
|
79
|
+
expect(url).toContain(TRANSCRIBE_CPP_SOURCE_REF);
|
|
80
|
+
expect(url.endsWith("/examples/cli/main.cpp")).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
test("source set covers the CLI + WAV loader + public header", () => {
|
|
83
|
+
expect(CLI_SOURCE_FILES).toContain("examples/cli/main.cpp");
|
|
84
|
+
expect(CLI_SOURCE_FILES).toContain("examples/common/wav.cpp");
|
|
85
|
+
expect(CLI_SOURCE_FILES).toContain("examples/common/wav.h");
|
|
86
|
+
expect(CLI_SOURCE_FILES).toContain("examples/common/dr_wav.h");
|
|
87
|
+
expect(CLI_SOURCE_FILES).toContain("include/transcribe.h");
|
|
88
|
+
// headers main.cpp includes directly
|
|
89
|
+
expect(CLI_SOURCE_FILES).toContain("include/transcribe/parakeet.h");
|
|
90
|
+
expect(CLI_SOURCE_FILES).toContain("include/transcribe/whisper.h");
|
|
91
|
+
expect(CLI_SOURCE_FILES).toContain("include/transcribe/voxtral_realtime.h");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("buildCompileCommand — the proven recipe shape", () => {
|
|
96
|
+
const base = {
|
|
97
|
+
compiler: "/usr/bin/c++",
|
|
98
|
+
srcDir: "/root/.parachute/transcription/.src",
|
|
99
|
+
libsDir: "/root/.parachute/transcription/libs",
|
|
100
|
+
outPath: "/root/.parachute/transcription/bin/transcribe-cli",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
test("macOS → @loader_path/../libs rpath", () => {
|
|
104
|
+
const cmd = buildCompileCommand({ ...base, platform: "darwin" });
|
|
105
|
+
expect(cmd[0]).toBe("/usr/bin/c++");
|
|
106
|
+
expect(cmd).toContain("-std=c++17");
|
|
107
|
+
expect(cmd).toContain(`-I${join(base.srcDir, "include")}`);
|
|
108
|
+
expect(cmd).toContain(`-I${join(base.srcDir, "examples", "common")}`);
|
|
109
|
+
expect(cmd).toContain(join(base.srcDir, "examples", "cli", "main.cpp"));
|
|
110
|
+
expect(cmd).toContain(join(base.srcDir, "examples", "common", "wav.cpp"));
|
|
111
|
+
expect(cmd).toContain(`-L${base.libsDir}`);
|
|
112
|
+
expect(cmd).toContain("-ltranscribe");
|
|
113
|
+
expect(cmd).toContain("-Wl,-rpath,@loader_path/../libs");
|
|
114
|
+
// output is the final -o <path>
|
|
115
|
+
expect(cmd[cmd.length - 2]).toBe("-o");
|
|
116
|
+
expect(cmd[cmd.length - 1]).toBe(base.outPath);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("Linux → $ORIGIN/../libs rpath (literal — passed to argv, not a shell)", () => {
|
|
120
|
+
const cmd = buildCompileCommand({ ...base, platform: "linux" });
|
|
121
|
+
expect(cmd).toContain("-Wl,-rpath,$ORIGIN/../libs");
|
|
122
|
+
expect(cmd).not.toContain("-Wl,-rpath,@loader_path/../libs");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/** Build a deps object with call-recording mocks; overrides win. */
|
|
127
|
+
function mkDeps(over: Partial<BuildCliDeps> = {}): BuildCliDeps & {
|
|
128
|
+
calls: {
|
|
129
|
+
fetch: number;
|
|
130
|
+
compile: number;
|
|
131
|
+
removed: string[];
|
|
132
|
+
renamed: Array<[string, string]>;
|
|
133
|
+
written: Array<[string, string]>;
|
|
134
|
+
};
|
|
135
|
+
} {
|
|
136
|
+
const calls = {
|
|
137
|
+
fetch: 0,
|
|
138
|
+
compile: 0,
|
|
139
|
+
removed: [] as string[],
|
|
140
|
+
renamed: [] as Array<[string, string]>,
|
|
141
|
+
written: [] as Array<[string, string]>,
|
|
142
|
+
};
|
|
143
|
+
const deps: BuildCliDeps = {
|
|
144
|
+
platform: "darwin",
|
|
145
|
+
which: (c) => (c === "c++" ? "/usr/bin/c++" : null),
|
|
146
|
+
fetchSource: async () => {
|
|
147
|
+
calls.fetch++;
|
|
148
|
+
},
|
|
149
|
+
readFile: () => MAIN_CPP_FIXTURE,
|
|
150
|
+
writeFile: (p, content) => {
|
|
151
|
+
calls.written.push([p, content]);
|
|
152
|
+
},
|
|
153
|
+
compile: async (): Promise<CompileResult> => {
|
|
154
|
+
calls.compile++;
|
|
155
|
+
return { exitCode: 0, stderr: "" };
|
|
156
|
+
},
|
|
157
|
+
exists: () => true,
|
|
158
|
+
removeBin: (p) => {
|
|
159
|
+
calls.removed.push(p);
|
|
160
|
+
},
|
|
161
|
+
rename: (from, to) => {
|
|
162
|
+
calls.renamed.push([from, to]);
|
|
163
|
+
},
|
|
164
|
+
...over,
|
|
165
|
+
};
|
|
166
|
+
return Object.assign(deps, { calls });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const INPUT = { srcDir: "/t/.src", libsDir: "/t/libs", binPath: "/t/bin/transcribe-cli" };
|
|
170
|
+
const TMP = `${INPUT.binPath}.building`; // the temp output the build compiles to
|
|
171
|
+
|
|
172
|
+
describe("patchMainCppBackendInit — the vault#534 backend-init source patch", () => {
|
|
173
|
+
test("injects transcribe_init_backends_default() before the batch-mode anchor", () => {
|
|
174
|
+
const patched = patchMainCppBackendInit(MAIN_CPP_FIXTURE);
|
|
175
|
+
expect(patched).toContain("transcribe_init_backends_default();");
|
|
176
|
+
expect(patched).toContain(BACKEND_INIT_PATCH_MARKER);
|
|
177
|
+
// The init call lands BEFORE the inference paths (the anchor) and AFTER
|
|
178
|
+
// the log-sink install, so backend plugin-load logs route through the sink.
|
|
179
|
+
const initAt = patched.indexOf("transcribe_init_backends_default();");
|
|
180
|
+
expect(initAt).toBeGreaterThan(patched.indexOf("transcribe_log_set(log_cb, nullptr);"));
|
|
181
|
+
expect(initAt).toBeLessThan(patched.indexOf(BACKEND_INIT_PATCH_ANCHOR));
|
|
182
|
+
// Everything else is preserved verbatim: removing the injected block
|
|
183
|
+
// restores the original source exactly.
|
|
184
|
+
const [before, after] = patched.split(/ \/\/ \[parachute-vault[\s\S]*?transcribe_init_backends_default\(\);\n\n/);
|
|
185
|
+
expect(`${before}${after}`).toBe(MAIN_CPP_FIXTURE);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("idempotent: an already-patched source is returned unchanged", () => {
|
|
189
|
+
const once = patchMainCppBackendInit(MAIN_CPP_FIXTURE);
|
|
190
|
+
expect(patchMainCppBackendInit(once)).toBe(once);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("anchor missing ⇒ throws a loud, actionable error naming the pin + vault#534", () => {
|
|
194
|
+
expect(() => patchMainCppBackendInit("int main() { return 0; }\n")).toThrow(
|
|
195
|
+
/anchor not found[\s\S]*vault#534/,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("buildTranscribeCli — orchestration branches", () => {
|
|
201
|
+
test("SUCCESS: fetch → patch → compile ok → temp exists ⇒ promote (rename) + { ok:true }", async () => {
|
|
202
|
+
const deps = mkDeps();
|
|
203
|
+
const r = await buildTranscribeCli(INPUT, deps);
|
|
204
|
+
expect(r.ok).toBe(true);
|
|
205
|
+
if (r.ok) {
|
|
206
|
+
expect(r.binPath).toBe(INPUT.binPath);
|
|
207
|
+
expect(r.compiler).toBe("/usr/bin/c++");
|
|
208
|
+
// reported command targets the FINAL binPath (reproducible by hand), not the temp
|
|
209
|
+
expect(r.command).toContain("-ltranscribe");
|
|
210
|
+
expect(r.command).toContain(`-o ${INPUT.binPath}`);
|
|
211
|
+
expect(r.command).not.toContain(TMP);
|
|
212
|
+
}
|
|
213
|
+
expect(deps.calls.fetch).toBe(1);
|
|
214
|
+
expect(deps.calls.compile).toBe(1);
|
|
215
|
+
// the vault#534 backend-init patch was written into the fetched main.cpp
|
|
216
|
+
expect(deps.calls.written).toHaveLength(1);
|
|
217
|
+
const [patchedPath, patchedContent] = deps.calls.written[0]!;
|
|
218
|
+
expect(patchedPath).toBe(join(INPUT.srcDir, "examples", "cli", "main.cpp"));
|
|
219
|
+
expect(patchedContent).toContain("transcribe_init_backends_default();");
|
|
220
|
+
// only the TEMP is cleared pre-compile; the fresh temp is renamed into place
|
|
221
|
+
expect(deps.calls.removed).toEqual([TMP]);
|
|
222
|
+
expect(deps.calls.renamed).toEqual([[TMP, INPUT.binPath]]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("PATCH FAILURE: anchor missing in fetched source ⇒ { ok:false, stage:'patch' }, no write, no compile", async () => {
|
|
226
|
+
const deps = mkDeps({ readFile: () => "int main() { return 0; }\n" });
|
|
227
|
+
const r = await buildTranscribeCli(INPUT, deps);
|
|
228
|
+
expect(r.ok).toBe(false);
|
|
229
|
+
if (!r.ok) {
|
|
230
|
+
expect(r.stage).toBe("patch");
|
|
231
|
+
expect(r.message).toContain("anchor not found");
|
|
232
|
+
expect(r.message).toContain("vault#534");
|
|
233
|
+
}
|
|
234
|
+
expect(deps.calls.written).toEqual([]);
|
|
235
|
+
expect(deps.calls.compile).toBe(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("TOOLCHAIN ABSENT: no compiler ⇒ { ok:false, stage:'toolchain' }, no fetch/compile", async () => {
|
|
239
|
+
const deps = mkDeps({ which: () => null });
|
|
240
|
+
const r = await buildTranscribeCli(INPUT, deps);
|
|
241
|
+
expect(r.ok).toBe(false);
|
|
242
|
+
if (!r.ok) {
|
|
243
|
+
expect(r.stage).toBe("toolchain");
|
|
244
|
+
expect(r.message).toContain("no C++ compiler");
|
|
245
|
+
expect(r.message).toContain("xcode-select"); // darwin hint embedded
|
|
246
|
+
}
|
|
247
|
+
expect(deps.calls.fetch).toBe(0);
|
|
248
|
+
expect(deps.calls.compile).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("FETCH FAILURE: fetchSource throws ⇒ { ok:false, stage:'fetch' }, no compile", async () => {
|
|
252
|
+
const deps = mkDeps({
|
|
253
|
+
fetchSource: async () => {
|
|
254
|
+
throw new Error("network down");
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
const r = await buildTranscribeCli(INPUT, deps);
|
|
258
|
+
expect(r.ok).toBe(false);
|
|
259
|
+
if (!r.ok) {
|
|
260
|
+
expect(r.stage).toBe("fetch");
|
|
261
|
+
expect(r.message).toContain("network down");
|
|
262
|
+
expect(r.message).toContain(TRANSCRIBE_CPP_SOURCE_REF);
|
|
263
|
+
}
|
|
264
|
+
expect(deps.calls.compile).toBe(0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("COMPILE FAILURE (nonzero exit): keeps provider + surfaces stderr + command; only the temp is cleaned", async () => {
|
|
268
|
+
const deps = mkDeps({
|
|
269
|
+
compile: async () => ({ exitCode: 1, stderr: "main.cpp:9: fatal error: transcribe.h not found" }),
|
|
270
|
+
});
|
|
271
|
+
const r = await buildTranscribeCli(INPUT, deps);
|
|
272
|
+
expect(r.ok).toBe(false);
|
|
273
|
+
if (!r.ok) {
|
|
274
|
+
expect(r.stage).toBe("compile");
|
|
275
|
+
expect(r.message).toContain("transcribe.h not found");
|
|
276
|
+
expect(r.command).toContain("-ltranscribe");
|
|
277
|
+
}
|
|
278
|
+
// ONLY the temp is touched (pre-clear + post-cleanup) — the real binPath is never removed
|
|
279
|
+
expect(deps.calls.removed).toEqual([TMP, TMP]);
|
|
280
|
+
expect(deps.calls.removed).not.toContain(INPUT.binPath);
|
|
281
|
+
expect(deps.calls.renamed).toEqual([]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("COMPILE 'succeeds' but output missing ⇒ { ok:false, stage:'compile' } + only temp cleaned", async () => {
|
|
285
|
+
const deps = mkDeps({ exists: () => false });
|
|
286
|
+
const r = await buildTranscribeCli(INPUT, deps);
|
|
287
|
+
expect(r.ok).toBe(false);
|
|
288
|
+
if (!r.ok) expect(r.stage).toBe("compile");
|
|
289
|
+
expect(deps.calls.removed).toEqual([TMP, TMP]);
|
|
290
|
+
expect(deps.calls.renamed).toEqual([]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("ATOMIC: a failed rebuild NEVER removes/renames the existing binary (--force safety)", async () => {
|
|
294
|
+
// Simulate an existing working binary present at binPath; the rebuild fails.
|
|
295
|
+
const deps = mkDeps({
|
|
296
|
+
exists: (p) => p === INPUT.binPath, // real binary exists; the temp never appears
|
|
297
|
+
compile: async () => ({ exitCode: 1, stderr: "boom" }),
|
|
298
|
+
});
|
|
299
|
+
const r = await buildTranscribeCli(INPUT, deps);
|
|
300
|
+
expect(r.ok).toBe(false);
|
|
301
|
+
// The existing binary is untouched: no removeBin/rename ever names binPath.
|
|
302
|
+
expect(deps.calls.removed).not.toContain(INPUT.binPath);
|
|
303
|
+
expect(deps.calls.renamed).toEqual([]);
|
|
304
|
+
});
|
|
305
|
+
});
|