@ashlr/stack-core 0.3.1 → 0.3.6
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/package.json +1 -1
- package/src/__tests__/_harness.ts +65 -39
- package/src/__tests__/apply-placeholder-history.test.ts +55 -0
- package/src/__tests__/apply-placeholder-operator-checklist.test.ts +28 -0
- package/src/__tests__/apply-terminology-examples.test.ts +46 -0
- package/src/__tests__/config.test.ts +36 -0
- package/src/__tests__/credential-staging.test.ts +7 -6
- package/src/__tests__/deps.test.ts +35 -0
- package/src/__tests__/detect-source.test.ts +97 -0
- package/src/__tests__/fixtures/stack-manifest.v1.json +3 -3
- package/src/__tests__/phantom-process.test.ts +21 -3
- package/src/__tests__/phantom-wire-failing-subprocess.test.ts +126 -0
- package/src/__tests__/phantom-wire-repeated-placeholders.test.ts +84 -0
- package/src/__tests__/phantom-wire-unicode-combining.test.ts +100 -0
- package/src/__tests__/phantom-wire.test.ts +6329 -363
- package/src/__tests__/pipeline-integration.test.ts +26 -2
- package/src/__tests__/recommend-apply-e2e.test.ts +2 -2
- package/src/__tests__/secret-redaction.test.ts +13 -3
- package/src/ai/phantom-wire-adjacent-different-providers.test.ts +78 -0
- package/src/ai/phantom-wire-adjacent-known-placeholders.test.ts +266 -0
- package/src/ai/phantom-wire-args.test.ts +63 -0
- package/src/ai/phantom-wire-blank-lines.test.ts +174 -0
- package/src/ai/phantom-wire-carriage-return.test.ts +125 -0
- package/src/ai/phantom-wire-case-changed-placeholder.test.ts +209 -0
- package/src/ai/phantom-wire-closing-brace.test.ts +16 -0
- package/src/ai/phantom-wire-crlf.test.ts +48 -0
- package/src/ai/phantom-wire-dollar-amp.test.ts +128 -0
- package/src/ai/phantom-wire-double-quote.test.ts +135 -0
- package/src/ai/phantom-wire-empty-replacement.test.ts +122 -0
- package/src/ai/phantom-wire-empty-stderr.test.ts +145 -0
- package/src/ai/phantom-wire-empty-template.test.ts +81 -0
- package/src/ai/phantom-wire-empty-values.test.ts +129 -0
- package/src/ai/phantom-wire-errors.test.ts +296 -0
- package/src/ai/phantom-wire-known-placeholder-adjacent-punctuation.test.ts +188 -0
- package/src/ai/phantom-wire-known-placeholder-at-end.test.ts +160 -0
- package/src/ai/phantom-wire-known-placeholder.test.ts +170 -0
- package/src/ai/phantom-wire-malformed-json.test.ts +227 -0
- package/src/ai/phantom-wire-missing.test.ts +123 -0
- package/src/ai/phantom-wire-mixed-placeholders.test.ts +183 -0
- package/src/ai/phantom-wire-nested-placeholder.test.ts +149 -0
- package/src/ai/phantom-wire-newline.test.ts +136 -0
- package/src/ai/phantom-wire-order.test.ts +173 -0
- package/src/ai/phantom-wire-placeholder-two-lines.test.ts +196 -0
- package/src/ai/phantom-wire-repeated-known-placeholder.test.ts +241 -0
- package/src/ai/phantom-wire-repeated-unknown-placeholder.test.ts +55 -0
- package/src/ai/phantom-wire-replacement-dollar.test.ts +170 -0
- package/src/ai/phantom-wire-single-quote.test.ts +22 -0
- package/src/ai/phantom-wire-tab.test.ts +42 -0
- package/src/ai/phantom-wire-unknown-placeholder.test.ts +189 -0
- package/src/ai/phantom-wire-vault-name-case.test.ts +46 -0
- package/src/ai/phantom-wire.test.ts +144 -0
- package/src/ai/phantom-wire.ts +11 -4
- package/src/phantom.ts +10 -1
- package/src/stack-manifest.ts +3 -3
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
symlinkSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { StackError } from "../errors.ts";
|
|
16
|
+
import { __resetPhantomCache, __setPhantomSpawn, exec, listSecrets } from "../phantom.ts";
|
|
17
|
+
import { wirePhantomForRecipe } from "./phantom-wire.ts";
|
|
18
|
+
import type { Recipe } from "./recipe.ts";
|
|
19
|
+
|
|
20
|
+
// Stubbed `phantom` subprocess, like the sibling phantom-wire-*.test.ts files,
|
|
21
|
+
// extended for the failure shapes a spawned child can report that a clean exit
|
|
22
|
+
// cannot: a signal that killed it, a `close` with no exit code, and spawn
|
|
23
|
+
// errors beyond ENOENT. `script` answers per argv, so `--version` can succeed
|
|
24
|
+
// while the inventory read fails. `calls` records every argv: whatever the
|
|
25
|
+
// failure, wiring may only ever probe (`--version`) or read (`list --json`) —
|
|
26
|
+
// never a mutating verb.
|
|
27
|
+
type ScriptResult = {
|
|
28
|
+
code?: number | null;
|
|
29
|
+
signal?: NodeJS.Signals | null;
|
|
30
|
+
stdout?: string;
|
|
31
|
+
stderr?: string;
|
|
32
|
+
error?: string;
|
|
33
|
+
};
|
|
34
|
+
let script: (args: string[]) => ScriptResult = () => ({ code: 0 });
|
|
35
|
+
let calls: string[][] = [];
|
|
36
|
+
|
|
37
|
+
function fakeSpawn(_bin: string, args: string[]) {
|
|
38
|
+
calls.push([...args]);
|
|
39
|
+
const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
|
|
40
|
+
const out = new EventEmitter();
|
|
41
|
+
const err = new EventEmitter();
|
|
42
|
+
Object.assign(child, {
|
|
43
|
+
stdout: Object.assign(out, { destroy() {} }),
|
|
44
|
+
stderr: Object.assign(err, { destroy() {} }),
|
|
45
|
+
stdin: { on() {}, write() {}, end() {}, destroy() {} },
|
|
46
|
+
pid: undefined,
|
|
47
|
+
kill() {},
|
|
48
|
+
unref() {},
|
|
49
|
+
});
|
|
50
|
+
queueMicrotask(() => {
|
|
51
|
+
const r = script(args);
|
|
52
|
+
if (r.error) {
|
|
53
|
+
child.emit("error", Object.assign(new Error(`spawn phantom ${r.error}`), { code: r.error }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (r.stdout) out.emit("data", Buffer.from(r.stdout));
|
|
57
|
+
if (r.stderr) err.emit("data", Buffer.from(r.stderr));
|
|
58
|
+
// An omitted code is a clean exit; explicit null is the shape Node reports
|
|
59
|
+
// for a child that never produced an exit status (signal or unknown end).
|
|
60
|
+
child.emit("close", r.code === undefined ? 0 : r.code, r.signal ?? null);
|
|
61
|
+
});
|
|
62
|
+
return child;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const INSTALLED: ScriptResult = { code: 0, stdout: "phantom 0.7.9\n" };
|
|
66
|
+
|
|
67
|
+
// `--version` answers; the inventory read fails as each test scripted it.
|
|
68
|
+
const failList = (result: ScriptResult) => (args: string[]) =>
|
|
69
|
+
args[0] === "list" ? result : INSTALLED;
|
|
70
|
+
|
|
71
|
+
const RECIPE: Recipe = {
|
|
72
|
+
id: "wire-errors",
|
|
73
|
+
query: "payments",
|
|
74
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
75
|
+
providers: [{ name: "stripe" }, { name: "neon" }],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
describe("ai/phantom-wire subprocess errors", () => {
|
|
79
|
+
const savedPath = process.env.PATH;
|
|
80
|
+
let binDir: string;
|
|
81
|
+
let projectDir: string;
|
|
82
|
+
let stubPath: string;
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
binDir = realpathSync(mkdtempSync(join(tmpdir(), "wire-errors-bin-")));
|
|
86
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), "wire-errors-")));
|
|
87
|
+
// The binary only needs to resolve on PATH; spawn is stubbed. The `.sh`
|
|
88
|
+
// target matches the sibling tests (macOS provenance policy).
|
|
89
|
+
writeFileSync(join(binDir, "phantom.sh"), "#!/bin/sh\n");
|
|
90
|
+
chmodSync(join(binDir, "phantom.sh"), 0o755);
|
|
91
|
+
symlinkSync(join(binDir, "phantom.sh"), join(binDir, "phantom"));
|
|
92
|
+
process.env.PATH = binDir;
|
|
93
|
+
stubPath = join(projectDir, ".stack/webhooks/stripe.ts");
|
|
94
|
+
script = () => ({ code: 0 });
|
|
95
|
+
calls = [];
|
|
96
|
+
__resetPhantomCache();
|
|
97
|
+
__setPhantomSpawn(fakeSpawn as never);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
process.env.PATH = savedPath;
|
|
102
|
+
rmSync(binDir, { recursive: true, force: true });
|
|
103
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterAll(() => {
|
|
107
|
+
__setPhantomSpawn();
|
|
108
|
+
__resetPhantomCache();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("reaches the caller as a failed read, never as success, when `list` is killed mid-stream", async () => {
|
|
112
|
+
// SIGKILL lands after stdout already carried a valid-looking inventory. A
|
|
113
|
+
// killed child cannot attest a completed read, so those bytes must never
|
|
114
|
+
// legitimize the name they contain.
|
|
115
|
+
script = failList({
|
|
116
|
+
code: null,
|
|
117
|
+
signal: "SIGKILL",
|
|
118
|
+
stdout: '[{"name":"STRIPE_SECRET_KEY"}]',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// exec() reports the close fields faithfully: a non-zero code, the signal,
|
|
122
|
+
// and a "signal" completion — distinguishable from a clean exit on its own.
|
|
123
|
+
const execResult = await exec(["list", "--json"], {
|
|
124
|
+
cwd: projectDir,
|
|
125
|
+
allowFailure: true,
|
|
126
|
+
});
|
|
127
|
+
expect(execResult).toMatchObject({ code: 1, signal: "SIGKILL", completion: "signal" });
|
|
128
|
+
expect(execResult.code).not.toBe(0);
|
|
129
|
+
|
|
130
|
+
// listSecrets' caller gets the typed unavailable-inventory error, not the
|
|
131
|
+
// plausible name the dying child emitted.
|
|
132
|
+
await expect(listSecrets(projectDir)).rejects.toMatchObject({
|
|
133
|
+
code: "PHANTOM_METADATA_UNAVAILABLE",
|
|
134
|
+
message: "Phantom secret inventory is unavailable; stored names are unknown.",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// And the wire caller sees every provider skipped — never `present` or
|
|
138
|
+
// `missing`, which only a completed read can justify.
|
|
139
|
+
expect(await wirePhantomForRecipe(RECIPE, { cwd: projectDir })).toEqual({
|
|
140
|
+
present: [],
|
|
141
|
+
missing: [],
|
|
142
|
+
webhooks: [stubPath],
|
|
143
|
+
skipped: ["stripe", "neon"],
|
|
144
|
+
});
|
|
145
|
+
expect(existsSync(stubPath)).toBe(true);
|
|
146
|
+
expect(calls).toEqual([
|
|
147
|
+
["list", "--json"],
|
|
148
|
+
["--version"],
|
|
149
|
+
["list", "--json"],
|
|
150
|
+
["list", "--json"],
|
|
151
|
+
]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("keeps a `close` with no exit code and no signal unknown, never a completed read", async () => {
|
|
155
|
+
script = failList({ code: null });
|
|
156
|
+
|
|
157
|
+
const execResult = await exec(["list", "--json"], {
|
|
158
|
+
cwd: projectDir,
|
|
159
|
+
allowFailure: true,
|
|
160
|
+
});
|
|
161
|
+
// No exit status and no signal: completion stays "unknown" and the code is
|
|
162
|
+
// non-zero — there is nothing a caller could mistake for success.
|
|
163
|
+
expect(execResult).toMatchObject({ code: 1, completion: "unknown" });
|
|
164
|
+
expect(execResult.signal).toBeUndefined();
|
|
165
|
+
|
|
166
|
+
await expect(listSecrets(projectDir)).rejects.toMatchObject({
|
|
167
|
+
code: "PHANTOM_METADATA_UNAVAILABLE",
|
|
168
|
+
});
|
|
169
|
+
expect(await wirePhantomForRecipe(RECIPE, { cwd: projectDir })).toEqual({
|
|
170
|
+
present: [],
|
|
171
|
+
missing: [],
|
|
172
|
+
webhooks: [stubPath],
|
|
173
|
+
skipped: ["stripe", "neon"],
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it.each(["EACCES", "EMFILE"])(
|
|
178
|
+
"delivers a refused `list` spawn (%s) to its caller unwrapped, never as an empty vault",
|
|
179
|
+
async (errno) => {
|
|
180
|
+
// ENOENT has a typed mapping (Phantom missing); every other spawn error
|
|
181
|
+
// reaches the caller as itself — unwrapped, not downgraded to a fake
|
|
182
|
+
// inventory or a not-installed hint.
|
|
183
|
+
script = failList({ error: errno });
|
|
184
|
+
|
|
185
|
+
await expect(exec(["list", "--json"], { cwd: projectDir })).rejects.toMatchObject({
|
|
186
|
+
code: errno,
|
|
187
|
+
});
|
|
188
|
+
const err = await listSecrets(projectDir).catch((e: unknown) => e);
|
|
189
|
+
expect(err).toMatchObject({ code: errno });
|
|
190
|
+
expect(err).not.toBeInstanceOf(StackError);
|
|
191
|
+
|
|
192
|
+
// The wire caller still gets a faithful answer: providers are skipped —
|
|
193
|
+
// the refused spawn never becomes `present`, `missing`, or an empty list.
|
|
194
|
+
expect(await wirePhantomForRecipe(RECIPE, { cwd: projectDir })).toEqual({
|
|
195
|
+
present: [],
|
|
196
|
+
missing: [],
|
|
197
|
+
webhooks: [stubPath],
|
|
198
|
+
skipped: ["stripe", "neon"],
|
|
199
|
+
});
|
|
200
|
+
expect(calls).toEqual([
|
|
201
|
+
["list", "--json"],
|
|
202
|
+
["--version"],
|
|
203
|
+
["list", "--json"],
|
|
204
|
+
["list", "--json"],
|
|
205
|
+
]);
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
it("treats a binary that vanishes between probe and read as missing — never an empty vault", async () => {
|
|
210
|
+
// `--version` answered, then `list` hits ENOENT: the not-installed mapping
|
|
211
|
+
// surfaces mid-wiring, and `skipped` keeps the providers' secrets unknown —
|
|
212
|
+
// `missing` would falsely claim they were verified absent.
|
|
213
|
+
script = failList({ error: "ENOENT" });
|
|
214
|
+
|
|
215
|
+
await expect(listSecrets(projectDir)).rejects.toMatchObject({
|
|
216
|
+
code: "PHANTOM_NOT_INSTALLED",
|
|
217
|
+
});
|
|
218
|
+
expect(await wirePhantomForRecipe(RECIPE, { cwd: projectDir })).toEqual({
|
|
219
|
+
present: [],
|
|
220
|
+
missing: [],
|
|
221
|
+
webhooks: [stubPath],
|
|
222
|
+
skipped: ["stripe", "neon"],
|
|
223
|
+
});
|
|
224
|
+
expect(calls).toEqual([["--version"], ["list", "--json"], ["list", "--json"]]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it.each<[string, ScriptResult]>([
|
|
228
|
+
["a refused spawn (EACCES)", { error: "EACCES" }],
|
|
229
|
+
["a signal kill (SIGTERM)", { code: null, signal: "SIGTERM" }],
|
|
230
|
+
])(
|
|
231
|
+
"maps %s on the `--version` probe to not-installed, never to a working phantom",
|
|
232
|
+
async (_label, probeResult) => {
|
|
233
|
+
script = (args) => (args[0] === "--version" ? probeResult : INSTALLED);
|
|
234
|
+
|
|
235
|
+
const expected = {
|
|
236
|
+
present: [],
|
|
237
|
+
missing: [],
|
|
238
|
+
webhooks: [stubPath],
|
|
239
|
+
skipped: ["stripe", "neon"],
|
|
240
|
+
};
|
|
241
|
+
expect(await wirePhantomForRecipe(RECIPE, { cwd: projectDir })).toEqual(expected);
|
|
242
|
+
expect(existsSync(stubPath)).toBe(true);
|
|
243
|
+
// The inventory read is never attempted.
|
|
244
|
+
expect(calls).toEqual([["--version"]]);
|
|
245
|
+
|
|
246
|
+
// A failed probe is cached for the process: a repeat request reports the
|
|
247
|
+
// same skipped result without re-spawning `--version`.
|
|
248
|
+
expect(await wirePhantomForRecipe(RECIPE, { cwd: projectDir })).toEqual(expected);
|
|
249
|
+
expect(calls).toEqual([["--version"]]);
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
it("does not cache a failed inventory read: the next call reports the recovered vault truthfully", async () => {
|
|
254
|
+
let listCalls = 0;
|
|
255
|
+
script = (args) => {
|
|
256
|
+
if (args[0] !== "list") return INSTALLED;
|
|
257
|
+
listCalls += 1;
|
|
258
|
+
return listCalls === 1
|
|
259
|
+
? { code: 1, stderr: "vault locked" }
|
|
260
|
+
: { code: 0, stdout: '[{"name":"STRIPE_SECRET_KEY"}]' };
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// The first caller gets the failure: both providers skipped, nothing
|
|
264
|
+
// claimed present or missing.
|
|
265
|
+
expect(await wirePhantomForRecipe(RECIPE, { cwd: projectDir })).toEqual({
|
|
266
|
+
present: [],
|
|
267
|
+
missing: [],
|
|
268
|
+
webhooks: [stubPath],
|
|
269
|
+
skipped: ["stripe", "neon"],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// The failure is not latched: once `list` answers, the next caller gets
|
|
273
|
+
// the real split — stored names present, absent names missing.
|
|
274
|
+
expect(await wirePhantomForRecipe(RECIPE, { cwd: projectDir })).toEqual({
|
|
275
|
+
present: ["stripe:STRIPE_SECRET_KEY"],
|
|
276
|
+
missing: ["neon:NEON_API_KEY", "neon:DATABASE_URL"],
|
|
277
|
+
webhooks: [stubPath],
|
|
278
|
+
skipped: [],
|
|
279
|
+
});
|
|
280
|
+
expect(calls).toEqual([["--version"], ["list", "--json"], ["list", "--json"]]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("rejects to the caller when a stub write fails on top of a failed read", async () => {
|
|
284
|
+
script = failList({ code: 1, stderr: "vault locked" });
|
|
285
|
+
// `stripe.ts` is a directory: writeFile refuses with EISDIR after the
|
|
286
|
+
// inventory read already failed.
|
|
287
|
+
mkdirSync(join(projectDir, ".stack/webhooks/stripe.ts"), { recursive: true });
|
|
288
|
+
|
|
289
|
+
// The write failure reaches the caller as the fs error itself — not a
|
|
290
|
+
// WireResult carrying half-finished state a caller could read as success.
|
|
291
|
+
await expect(wirePhantomForRecipe(RECIPE, { cwd: projectDir })).rejects.toMatchObject({
|
|
292
|
+
code: "EISDIR",
|
|
293
|
+
});
|
|
294
|
+
expect(calls).toEqual([["--version"], ["list", "--json"]]);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
|
|
6
|
+
import { verificationHintFor, webhookStubFor, wirePhantomForRecipe } from "./phantom-wire.ts";
|
|
7
|
+
import type { Recipe } from "./recipe.ts";
|
|
8
|
+
|
|
9
|
+
// The wiring check spawns the fake `phantom` shim (bash + python3); keep the
|
|
10
|
+
// sibling files' raised timeout.
|
|
11
|
+
setDefaultTimeout(30_000);
|
|
12
|
+
|
|
13
|
+
const count = (s: string, needle: string) => s.split(needle).length - 1;
|
|
14
|
+
|
|
15
|
+
describe("ai/phantom-wire known placeholder adjacent to punctuation", () => {
|
|
16
|
+
let harness: Harness;
|
|
17
|
+
let dir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
harness = setupFakePhantom();
|
|
21
|
+
dir = mkdtempSync(join(tmpdir(), "wire-punct-adjacent-"));
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
harness.cleanup();
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
29
|
+
id: "punct-adjacent",
|
|
30
|
+
query: "q",
|
|
31
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
32
|
+
providers: names.map((name) => ({ name })),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// The template's own marks (periods, parens, quotes, colons, dashes)
|
|
36
|
+
// measured off a mark-free name, so the per-mark arithmetic tracks
|
|
37
|
+
// template drift instead of pinning counts by hand.
|
|
38
|
+
const baseline = webhookStubFor("zzq");
|
|
39
|
+
|
|
40
|
+
it.each([
|
|
41
|
+
"pay.${provider}!", // `.` before and `!` after, mid-name
|
|
42
|
+
"(${capitalize(provider)})", // parens flush around the token
|
|
43
|
+
"hook:${webhookSecretKeyFor(provider)};", // `:` before, `;` after
|
|
44
|
+
"a,${signatureHeaderFor(provider)}?", // `,` before, `?` after
|
|
45
|
+
"x-${verificationHintFor(provider)}", // `-` before a trailing token
|
|
46
|
+
"${provider}.hook", // `.` after a leading token
|
|
47
|
+
])("keeps the marks flush against the carried token at every site for %j", (name) => {
|
|
48
|
+
// Each name is ordinary text carrying one of the template's own slot
|
|
49
|
+
// spellings — a known placeholder — with punctuation flush on at least
|
|
50
|
+
// one side. Both are data: expansion fills the template's slots with the
|
|
51
|
+
// whole name, so the marks must land byte-for-byte beside the token,
|
|
52
|
+
// neither consumed into the slot's syntax nor split off it — while the
|
|
53
|
+
// token itself never resolves to the value its spelling names.
|
|
54
|
+
const stub = webhookStubFor(name);
|
|
55
|
+
const token = name.match(/\$\{[^}]*\}/)?.[0] as string;
|
|
56
|
+
const at = name.indexOf(token);
|
|
57
|
+
const left = name.slice(0, at);
|
|
58
|
+
const right = name.slice(at + token.length);
|
|
59
|
+
|
|
60
|
+
// The name — token and marks together — lands byte-for-byte at the
|
|
61
|
+
// three case-preserving sites and whole-name uppercased at the two
|
|
62
|
+
// secret-key sites. `capitalize` folds only the first code point: a
|
|
63
|
+
// letter-led name folds (`pay` → `Pay`); a mark- or `$`-led name passes
|
|
64
|
+
// through unchanged.
|
|
65
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
66
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
67
|
+
expect(stub).toContain(`handle${name[0].toUpperCase()}${name.slice(1)}Webhook(req: Request)`);
|
|
68
|
+
expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
|
|
69
|
+
|
|
70
|
+
// Flank census: at every occurrence of the token — exact case at the
|
|
71
|
+
// three mixed-case sites, uppercased at the two reveal sites — the
|
|
72
|
+
// bytes immediately beside it are the name's own flanks (in that site's
|
|
73
|
+
// case), never the template's text. A slot-skipping render would leave
|
|
74
|
+
// the bare token hugged by `for `/`.`; a mark-eating one would show the
|
|
75
|
+
// name's letters or the template beside it instead.
|
|
76
|
+
const contexts: string[] = [];
|
|
77
|
+
for (const variant of [token, token.toUpperCase()]) {
|
|
78
|
+
for (let i = stub.indexOf(variant); i !== -1; i = stub.indexOf(variant, i + 1)) {
|
|
79
|
+
const l = stub.slice(i - left.length, i);
|
|
80
|
+
const r = stub.slice(i + variant.length, i + variant.length + right.length);
|
|
81
|
+
contexts.push(`${l}|${r}`.toLowerCase());
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
expect(contexts).toEqual(Array.from({ length: 5 }, () => `${left}|${right}`.toLowerCase()));
|
|
85
|
+
|
|
86
|
+
// Five `${...}` spans — one per expansion site — every one the carried
|
|
87
|
+
// token in that site's case: none resolved to the value its spelling
|
|
88
|
+
// names, none dropped, none duplicated.
|
|
89
|
+
const spans = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
|
|
90
|
+
expect(spans).toHaveLength(5);
|
|
91
|
+
for (const span of spans) expect(span.toLowerCase()).toBe(token.toLowerCase());
|
|
92
|
+
|
|
93
|
+
// Mark accounting: every punctuation mark in the stub is either one the
|
|
94
|
+
// template contributes itself or one the name carried into its five
|
|
95
|
+
// expansion sites — none eaten, none doubled. The token's own `${`/`}`
|
|
96
|
+
// bytes are tracked by the span census above.
|
|
97
|
+
for (const mark of new Set(name.match(/[^a-zA-Z0-9${}]/g) ?? [])) {
|
|
98
|
+
expect(count(stub, mark)).toBe(count(baseline, mark) + 5 * count(name, mark));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A second pass would re-resolve the carried token into the whole name,
|
|
102
|
+
// splicing the flanks around it again; neither flank ever repeats.
|
|
103
|
+
if (left) expect(stub).not.toContain(`${left}${left}`);
|
|
104
|
+
if (right) expect(stub).not.toContain(`${right}${right}`);
|
|
105
|
+
|
|
106
|
+
// The remaining slots took their own generic values in the same single
|
|
107
|
+
// pass — the carried token never reached their switch cases either.
|
|
108
|
+
expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
|
|
109
|
+
expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
|
|
110
|
+
expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("pins `pay.${provider}!` line-for-line — the slot took the whole value, the marks stayed put", () => {
|
|
114
|
+
// The sharpest form: the carried token spells the name slot itself, so
|
|
115
|
+
// at the name sites the emitted text is indistinguishable from an
|
|
116
|
+
// unresolved slot. Exact-line equality pins every site: the slot was
|
|
117
|
+
// replaced once by `pay.${provider}!` — the `.` before and the `!`
|
|
118
|
+
// after the token arriving as data — and the template's own `.`
|
|
119
|
+
// follows the whole value untouched.
|
|
120
|
+
const stub = webhookStubFor("pay.${provider}!");
|
|
121
|
+
|
|
122
|
+
expect(stub).toContain(
|
|
123
|
+
"// Auto-generated by `stack apply` — webhook handler stub for pay.${provider}!.",
|
|
124
|
+
);
|
|
125
|
+
expect(stub).toContain("// phantom reveal PAY.${PROVIDER}!_WEBHOOK_SECRET");
|
|
126
|
+
expect(stub).toContain(
|
|
127
|
+
"// export async function handlePay.${provider}!Webhook(req: Request): Promise<Response> {",
|
|
128
|
+
);
|
|
129
|
+
expect(stub).toContain("// // to this endpoint can forge a pay.${provider}! webhook.");
|
|
130
|
+
expect(stub).toContain(
|
|
131
|
+
"// // (`phantom reveal PAY.${PROVIDER}!_WEBHOOK_SECRET`) and compare in",
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Census: exactly five `${...}` spans — three original-case at the
|
|
135
|
+
// case-preserving sites, two folded at the reveal sites. A second pass
|
|
136
|
+
// would re-resolve each `${provider}` into `pay.${provider}!` again,
|
|
137
|
+
// doubling the `pay.` prefix and the `!` suffix; neither appears.
|
|
138
|
+
const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
|
|
139
|
+
expect(tokens).toEqual([
|
|
140
|
+
"${provider}",
|
|
141
|
+
"${PROVIDER}",
|
|
142
|
+
"${provider}",
|
|
143
|
+
"${provider}",
|
|
144
|
+
"${PROVIDER}",
|
|
145
|
+
]);
|
|
146
|
+
expect(stub).not.toContain("pay.pay");
|
|
147
|
+
expect(stub).not.toContain("!!");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("skips a mark-adjacent placeholder name byte-for-byte through wiring and writes nothing for it", async () => {
|
|
151
|
+
// Through `wirePhantomForRecipe` the name matches no catalog provider:
|
|
152
|
+
// it returns in `skipped` byte-for-byte — marks and token intact — gets
|
|
153
|
+
// no stub on disk, and provokes no vault write. The known provider
|
|
154
|
+
// listed beside it wires normally, unaffected by the mark-wrapped,
|
|
155
|
+
// placeholder-shaped neighbour.
|
|
156
|
+
const name = "pay.${provider}!";
|
|
157
|
+
const stubPath = join(dir, ".stack", "webhooks", "supabase.ts");
|
|
158
|
+
const result = await wirePhantomForRecipe(recipe(name, "supabase"), { cwd: dir });
|
|
159
|
+
|
|
160
|
+
expect(result).toEqual({
|
|
161
|
+
present: [],
|
|
162
|
+
missing: [
|
|
163
|
+
"supabase:SUPABASE_URL",
|
|
164
|
+
"supabase:SUPABASE_ANON_KEY",
|
|
165
|
+
"supabase:SUPABASE_SERVICE_ROLE_KEY",
|
|
166
|
+
],
|
|
167
|
+
webhooks: [stubPath],
|
|
168
|
+
skipped: [name],
|
|
169
|
+
});
|
|
170
|
+
expect(result.skipped[0]).toBe(name);
|
|
171
|
+
|
|
172
|
+
// Only supabase's documented stub lands — nothing for the mark-carrying
|
|
173
|
+
// name, and the stub on disk is the same render `webhookStubFor`
|
|
174
|
+
// returns, fully substituted.
|
|
175
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["supabase.ts"]);
|
|
176
|
+
expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("supabase"));
|
|
177
|
+
expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
|
|
178
|
+
|
|
179
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
180
|
+
// still parses to an empty object.
|
|
181
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
182
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
183
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
184
|
+
|
|
185
|
+
// The hint is a fixed string: a mark-adjacent token never leaks into it.
|
|
186
|
+
expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
|
|
6
|
+
import { verificationHintFor, webhookStubFor, wirePhantomForRecipe } from "./phantom-wire.ts";
|
|
7
|
+
import type { Recipe } from "./recipe.ts";
|
|
8
|
+
|
|
9
|
+
// The wiring check spawns the fake `phantom` shim (bash + python3); keep the
|
|
10
|
+
// sibling files' raised timeout.
|
|
11
|
+
setDefaultTimeout(30_000);
|
|
12
|
+
|
|
13
|
+
const count = (s: string, needle: string) => s.split(needle).length - 1;
|
|
14
|
+
|
|
15
|
+
describe("ai/phantom-wire known placeholder at the end of a template", () => {
|
|
16
|
+
let harness: Harness;
|
|
17
|
+
let dir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
harness = setupFakePhantom();
|
|
21
|
+
dir = mkdtempSync(join(tmpdir(), "wire-trailing-known-"));
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
harness.cleanup();
|
|
25
|
+
rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const recipe = (...names: string[]): Recipe => ({
|
|
29
|
+
id: "trailing-known-placeholder",
|
|
30
|
+
query: "q",
|
|
31
|
+
createdAt: "2026-04-18T00:00:00.000Z",
|
|
32
|
+
providers: names.map((name) => ({ name })),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each([
|
|
36
|
+
"${provider}", // the name slot's own spelling
|
|
37
|
+
"${capitalize(provider)}", // the handler-name slot's spelling
|
|
38
|
+
"${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
|
|
39
|
+
"${signatureHeaderFor(provider)}", // the header slot's spelling
|
|
40
|
+
"${verificationHintFor(provider)}", // the hint slot's spelling
|
|
41
|
+
])(
|
|
42
|
+
"emits the trailing token %j verbatim with the preceding text intact at every site",
|
|
43
|
+
(slot) => {
|
|
44
|
+
// The name is ordinary text ending in one of the template's own slot
|
|
45
|
+
// spellings — a known placeholder at the very end, with nothing after
|
|
46
|
+
// it. `pay` is the preceding text the substitution must preserve.
|
|
47
|
+
// Because the token would resolve if inserted text were ever fed back
|
|
48
|
+
// through the slots, this is also a re-expansion case: a second pass
|
|
49
|
+
// would splice the whole name in again and double the `pay` prefix.
|
|
50
|
+
const name = `pay${slot}`;
|
|
51
|
+
const stub = webhookStubFor(name);
|
|
52
|
+
|
|
53
|
+
// The slot was replaced by the full name — prefix plus token — at the
|
|
54
|
+
// three case-preserving sites. A skipped slot would emit the bare
|
|
55
|
+
// `${provider}` after `for `; the `pay` prefix landing before the token
|
|
56
|
+
// proves the value was substituted whole.
|
|
57
|
+
expect(stub).toContain(`webhook handler stub for ${name}.`);
|
|
58
|
+
expect(stub).toContain(`forge a ${name} webhook.`);
|
|
59
|
+
// `capitalize` folds only the name's first letter: `pay` → `Pay`, and
|
|
60
|
+
// the trailing token rides along untouched.
|
|
61
|
+
expect(stub).toContain(`handlePay${slot}Webhook(req: Request)`);
|
|
62
|
+
// Both `toUpperCase()` secret-key sites fold prefix and token together —
|
|
63
|
+
// work only the substitution path does.
|
|
64
|
+
expect(count(stub, `phantom reveal PAY${slot.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
|
|
65
|
+
|
|
66
|
+
// Five `${...}` spans — one per expansion site — every one the carried
|
|
67
|
+
// token in that site's case. Each sits flush at the END of the emitted
|
|
68
|
+
// name: the three bytes in front of it are always the name's own `pay`
|
|
69
|
+
// prefix (in whatever case that site's transform gives it), never the
|
|
70
|
+
// template's fixed text — proof the slot was replaced, not left raw.
|
|
71
|
+
const spans = [...stub.matchAll(/\$\{[^}]*\}/g)];
|
|
72
|
+
expect(spans).toHaveLength(5);
|
|
73
|
+
for (const span of spans) {
|
|
74
|
+
expect(span[0].toLowerCase()).toBe(slot.toLowerCase());
|
|
75
|
+
const at = span.index as number;
|
|
76
|
+
expect(stub.slice(at - 3, at).toLowerCase()).toBe("pay");
|
|
77
|
+
}
|
|
78
|
+
// And each is the last thing the name contributes — the template's own
|
|
79
|
+
// post-slot text (`.`, `Webhook`, ` webhook.`, `_WEBHOOK_SECRET`) picks
|
|
80
|
+
// up right where the token ends, never eaten or doubled.
|
|
81
|
+
expect(stub).not.toMatch(/paypay/i);
|
|
82
|
+
expect(count(stub, "_WEBHOOK_SECRET")).toBe(2);
|
|
83
|
+
|
|
84
|
+
// The remaining slots took their own generic values in the same single
|
|
85
|
+
// pass — the carried token never reached their switch cases either.
|
|
86
|
+
expect(stub).toContain('req.headers.get("x-webhook-signature")');
|
|
87
|
+
expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
|
|
88
|
+
expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
it("pins `pay${provider}` line-for-line — the slot took the value, the prefix survived", () => {
|
|
93
|
+
// The sharpest form: the trailing token spells the name slot itself, so
|
|
94
|
+
// at the name sites the emitted text is indistinguishable from an
|
|
95
|
+
// unresolved slot. Exact-line equality pins every site: the slot was
|
|
96
|
+
// replaced by the name once, the `pay` prefix before it is intact, and
|
|
97
|
+
// no second pass spliced the name around the token again.
|
|
98
|
+
const stub = webhookStubFor("pay${provider}");
|
|
99
|
+
|
|
100
|
+
expect(stub).toContain(
|
|
101
|
+
"// Auto-generated by `stack apply` — webhook handler stub for pay${provider}.",
|
|
102
|
+
);
|
|
103
|
+
expect(stub).toContain("// phantom reveal PAY${PROVIDER}_WEBHOOK_SECRET");
|
|
104
|
+
expect(stub).toContain(
|
|
105
|
+
"// export async function handlePay${provider}Webhook(req: Request): Promise<Response> {",
|
|
106
|
+
);
|
|
107
|
+
expect(stub).toContain("// // to this endpoint can forge a pay${provider} webhook.");
|
|
108
|
+
expect(stub).toContain(
|
|
109
|
+
"// // (`phantom reveal PAY${PROVIDER}_WEBHOOK_SECRET`) and compare in",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Census: exactly five `${...}` spans — three original-case at the
|
|
113
|
+
// case-preserving sites, two folded at the reveal sites. Re-expansion
|
|
114
|
+
// would have produced `paypay${provider}` at every site; none appears.
|
|
115
|
+
const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
|
|
116
|
+
expect(tokens).toEqual([
|
|
117
|
+
"${provider}",
|
|
118
|
+
"${PROVIDER}",
|
|
119
|
+
"${provider}",
|
|
120
|
+
"${provider}",
|
|
121
|
+
"${PROVIDER}",
|
|
122
|
+
]);
|
|
123
|
+
expect(stub).not.toMatch(/paypay/i);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("skips a trailing-placeholder name byte-for-byte through wiring and writes nothing for it", async () => {
|
|
127
|
+
// Through `wirePhantomForRecipe` the name matches no catalog provider:
|
|
128
|
+
// it returns in `skipped` byte-for-byte — `pay` prefix and trailing
|
|
129
|
+
// token intact — gets no stub on disk, and provokes no vault write. The
|
|
130
|
+
// known provider listed beside it wires normally, unaffected by the
|
|
131
|
+
// placeholder-shaped neighbour.
|
|
132
|
+
const name = "pay${provider}";
|
|
133
|
+
const stubPath = join(dir, ".stack", "webhooks", "github.ts");
|
|
134
|
+
const result = await wirePhantomForRecipe(recipe(name, "github"), { cwd: dir });
|
|
135
|
+
|
|
136
|
+
expect(result).toEqual({
|
|
137
|
+
present: [],
|
|
138
|
+
missing: ["github:GITHUB_TOKEN"],
|
|
139
|
+
webhooks: [stubPath],
|
|
140
|
+
skipped: [name],
|
|
141
|
+
});
|
|
142
|
+
expect(result.skipped[0]).toBe(name);
|
|
143
|
+
|
|
144
|
+
// Only github's documented stub lands — nothing for the
|
|
145
|
+
// placeholder-ended name, and the stub on disk is the same render
|
|
146
|
+
// `webhookStubFor` returns, fully substituted.
|
|
147
|
+
expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["github.ts"]);
|
|
148
|
+
expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("github"));
|
|
149
|
+
expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
|
|
150
|
+
|
|
151
|
+
// The whole call log is reads (`--version`, `list`); the vault file
|
|
152
|
+
// still parses to an empty object.
|
|
153
|
+
expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
|
|
154
|
+
expect(harness.callsTo("add")).toEqual([]);
|
|
155
|
+
expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
|
|
156
|
+
|
|
157
|
+
// The hint is a fixed string: a trailing-token name never leaks into it.
|
|
158
|
+
expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
|
|
159
|
+
});
|
|
160
|
+
});
|