@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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/_harness.ts +65 -39
  3. package/src/__tests__/apply-placeholder-history.test.ts +55 -0
  4. package/src/__tests__/apply-placeholder-operator-checklist.test.ts +28 -0
  5. package/src/__tests__/apply-terminology-examples.test.ts +46 -0
  6. package/src/__tests__/config.test.ts +36 -0
  7. package/src/__tests__/credential-staging.test.ts +7 -6
  8. package/src/__tests__/deps.test.ts +35 -0
  9. package/src/__tests__/detect-source.test.ts +97 -0
  10. package/src/__tests__/fixtures/stack-manifest.v1.json +3 -3
  11. package/src/__tests__/phantom-process.test.ts +21 -3
  12. package/src/__tests__/phantom-wire-failing-subprocess.test.ts +126 -0
  13. package/src/__tests__/phantom-wire-repeated-placeholders.test.ts +84 -0
  14. package/src/__tests__/phantom-wire-unicode-combining.test.ts +100 -0
  15. package/src/__tests__/phantom-wire.test.ts +6329 -363
  16. package/src/__tests__/pipeline-integration.test.ts +26 -2
  17. package/src/__tests__/recommend-apply-e2e.test.ts +2 -2
  18. package/src/__tests__/secret-redaction.test.ts +13 -3
  19. package/src/ai/phantom-wire-adjacent-different-providers.test.ts +78 -0
  20. package/src/ai/phantom-wire-adjacent-known-placeholders.test.ts +266 -0
  21. package/src/ai/phantom-wire-args.test.ts +63 -0
  22. package/src/ai/phantom-wire-blank-lines.test.ts +174 -0
  23. package/src/ai/phantom-wire-carriage-return.test.ts +125 -0
  24. package/src/ai/phantom-wire-case-changed-placeholder.test.ts +209 -0
  25. package/src/ai/phantom-wire-closing-brace.test.ts +16 -0
  26. package/src/ai/phantom-wire-crlf.test.ts +48 -0
  27. package/src/ai/phantom-wire-dollar-amp.test.ts +128 -0
  28. package/src/ai/phantom-wire-double-quote.test.ts +135 -0
  29. package/src/ai/phantom-wire-empty-replacement.test.ts +122 -0
  30. package/src/ai/phantom-wire-empty-stderr.test.ts +145 -0
  31. package/src/ai/phantom-wire-empty-template.test.ts +81 -0
  32. package/src/ai/phantom-wire-empty-values.test.ts +129 -0
  33. package/src/ai/phantom-wire-errors.test.ts +296 -0
  34. package/src/ai/phantom-wire-known-placeholder-adjacent-punctuation.test.ts +188 -0
  35. package/src/ai/phantom-wire-known-placeholder-at-end.test.ts +160 -0
  36. package/src/ai/phantom-wire-known-placeholder.test.ts +170 -0
  37. package/src/ai/phantom-wire-malformed-json.test.ts +227 -0
  38. package/src/ai/phantom-wire-missing.test.ts +123 -0
  39. package/src/ai/phantom-wire-mixed-placeholders.test.ts +183 -0
  40. package/src/ai/phantom-wire-nested-placeholder.test.ts +149 -0
  41. package/src/ai/phantom-wire-newline.test.ts +136 -0
  42. package/src/ai/phantom-wire-order.test.ts +173 -0
  43. package/src/ai/phantom-wire-placeholder-two-lines.test.ts +196 -0
  44. package/src/ai/phantom-wire-repeated-known-placeholder.test.ts +241 -0
  45. package/src/ai/phantom-wire-repeated-unknown-placeholder.test.ts +55 -0
  46. package/src/ai/phantom-wire-replacement-dollar.test.ts +170 -0
  47. package/src/ai/phantom-wire-single-quote.test.ts +22 -0
  48. package/src/ai/phantom-wire-tab.test.ts +42 -0
  49. package/src/ai/phantom-wire-unknown-placeholder.test.ts +189 -0
  50. package/src/ai/phantom-wire-vault-name-case.test.ts +46 -0
  51. package/src/ai/phantom-wire.test.ts +144 -0
  52. package/src/ai/phantom-wire.ts +11 -4
  53. package/src/phantom.ts +10 -1
  54. 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
+ });