@ashlr/stack-core 0.3.1 → 0.3.7

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 +6381 -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,183 @@
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
+ // A `${...}` token that spells none of the template's slots. Already
16
+ // uppercase, so even the `toUpperCase()` secret-key sites hold identical
17
+ // bytes — any difference it shows is the renderer's, not the fold's.
18
+ const UNKNOWN = "${HOOK_URL}";
19
+
20
+ describe("ai/phantom-wire a name mixing a known and an unknown placeholder", () => {
21
+ let harness: Harness;
22
+ let dir: string;
23
+
24
+ beforeEach(() => {
25
+ harness = setupFakePhantom();
26
+ dir = mkdtempSync(join(tmpdir(), "wire-mixed-placeholders-"));
27
+ });
28
+ afterEach(() => {
29
+ harness.cleanup();
30
+ rmSync(dir, { recursive: true, force: true });
31
+ });
32
+
33
+ const recipe = (...names: string[]): Recipe => ({
34
+ id: "mixed-placeholders",
35
+ query: "q",
36
+ createdAt: "2026-04-18T00:00:00.000Z",
37
+ providers: names.map((name) => ({ name })),
38
+ });
39
+
40
+ it.each([
41
+ "${provider}", // the name slot's own spelling
42
+ "${capitalize(provider)}", // the handler-name slot's spelling
43
+ "${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
44
+ "${signatureHeaderFor(provider)}", // the header slot's spelling
45
+ "${verificationHintFor(provider)}", // the hint slot's spelling
46
+ ])(
47
+ "expands every known slot to the name while the unknown token in it stays literal: %j",
48
+ (slot) => {
49
+ // The name carries two `${...}` tokens separated by ordinary text:
50
+ // one spelling a real template slot (a known placeholder), one
51
+ // spelling nothing (unknown). The template's known placeholders
52
+ // still expand — each slot emits the whole name once — while both
53
+ // tokens inside the emitted name arrive unchanged: the known one is
54
+ // never offered a second pass, and the unknown one has no expansion
55
+ // to begin with.
56
+ const name = `pre${slot}mid${UNKNOWN}post`;
57
+ const stub = webhookStubFor(name);
58
+
59
+ // The `${provider}` and `${capitalize(provider)}` slots expanded to
60
+ // the full name — both tokens included — byte-for-byte at the three
61
+ // case-preserving sites; `capitalize` folds only the leading `p`.
62
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
63
+ expect(stub).toContain(`forge a ${name} webhook.`);
64
+ expect(stub).toContain(`handlePre${slot}mid${UNKNOWN}postWebhook(req: Request)`);
65
+ // The `webhookSecretKeyFor(provider)` slot expanded too: both reveal
66
+ // lines hold the whole-name-uppercased replacement — the carried
67
+ // known token folded with it, the already-uppercase unknown token
68
+ // byte-identical.
69
+ expect(
70
+ count(stub, `phantom reveal PRE${slot.toUpperCase()}MID${UNKNOWN}POST_WEBHOOK_SECRET`),
71
+ ).toBe(2);
72
+
73
+ // Census: two tokens per name × five expansion sites, exactly half
74
+ // the known spelling and half the unknown — none resolved away,
75
+ // emptied, or minted into extra copies.
76
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
77
+ expect(tokens).toHaveLength(10);
78
+ expect(tokens.filter((t) => t.toLowerCase() === slot.toLowerCase())).toHaveLength(5);
79
+ expect(tokens.filter((t) => t === UNKNOWN)).toHaveLength(5);
80
+
81
+ // Order and seating per site: at each of the five sites the emitted
82
+ // name keeps the known token before `mid` and the unknown token
83
+ // after it — the unknown placeholder remains exactly where the name
84
+ // put it, never transposed or reseated beside injected text.
85
+ expect(stub.match(/pre\$\{[^}]*\}mid\$\{HOOK_URL\}post/gi)).toHaveLength(5);
86
+
87
+ // Single pass: a re-resolve of the carried known token would double
88
+ // the flanking text or drop the unknown token; neither happened.
89
+ expect(stub).not.toMatch(/prepre|midmid|postpost/i);
90
+
91
+ // The remaining known slots took their own generic values in the
92
+ // same pass — expansion ran everywhere it was defined.
93
+ expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
94
+ expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
95
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
96
+ },
97
+ );
98
+
99
+ it.each([
100
+ ["${provider}", UNKNOWN], // known spelling leads, unknown follows
101
+ [UNKNOWN, "${provider}"], // unknown leads, known spelling follows
102
+ ])(
103
+ "keeps the flush pair %j + %j adjacent and in order at every expansion site",
104
+ (known, unknown) => {
105
+ // The whole name is the two tokens back-to-back: a known slot
106
+ // spelling flush against an unknown one. The known placeholder in
107
+ // the template expands — the slots emit the pair as the replacement
108
+ // — while the pair itself stays data: in order, nothing spliced
109
+ // between the first token's `}` and the second's `${`, the unknown
110
+ // token unchanged.
111
+ const name = `${known}${unknown}`;
112
+ const other = `${unknown}${known}`;
113
+ const stub = webhookStubFor(name);
114
+
115
+ // The `$`-led name is carried byte-for-byte at the three
116
+ // case-preserving sites (`capitalize` leaves it) and whole-name
117
+ // uppercased at both secret-key sites.
118
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
119
+ expect(stub).toContain(`forge a ${name} webhook.`);
120
+ expect(stub).toContain(`handle${name}Webhook(req: Request)`);
121
+ expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
122
+
123
+ // The flush pair occurs exact-case at the three mixed-case sites
124
+ // and uppercased together at the two reveal sites — at each site
125
+ // the second token starts on the very byte the first ends.
126
+ expect(stub.match(/\$\{[^}]*\}\$\{[^}]*\}/g)).toHaveLength(5);
127
+ expect(count(stub, name)).toBe(3);
128
+ expect(count(stub, name.toUpperCase())).toBe(2);
129
+
130
+ // The transposed order never lands, in either casing.
131
+ expect(stub).not.toContain(other);
132
+ expect(stub).not.toContain(other.toUpperCase());
133
+
134
+ // Census: five occurrences of each token — the known spelling and
135
+ // the unknown one — and nothing else placeholder-shaped.
136
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
137
+ expect(tokens).toHaveLength(10);
138
+ expect(tokens.filter((t) => t.toLowerCase() === known.toLowerCase())).toHaveLength(5);
139
+ expect(tokens.filter((t) => t.toLowerCase() === unknown.toLowerCase())).toHaveLength(5);
140
+
141
+ // The template's real slots still take their own known values.
142
+ expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
143
+ expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
144
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
145
+ },
146
+ );
147
+
148
+ it("skips a mixed-token name byte-for-byte through wiring and writes nothing for it", async () => {
149
+ // Through `wirePhantomForRecipe` the mixed-token name matches no
150
+ // catalog provider: it returns in `skipped` byte-for-byte — the
151
+ // known-spelling token, the unknown token and the text between them
152
+ // intact — gets no stub on disk, and provokes no vault write. The
153
+ // known provider listed beside it wires normally: every one of its
154
+ // placeholders expands, unaffected by the placeholder-shaped
155
+ // neighbour.
156
+ const name = "pay${provider}load${HOOK_URL}end";
157
+ const stubPath = join(dir, ".stack", "webhooks", "clerk.ts");
158
+ const result = await wirePhantomForRecipe(recipe(name, "clerk"), { cwd: dir });
159
+
160
+ expect(result).toEqual({
161
+ present: [],
162
+ missing: ["clerk:CLERK_SECRET_KEY"],
163
+ webhooks: [stubPath],
164
+ skipped: [name],
165
+ });
166
+ expect(result.skipped[0]).toBe(name);
167
+
168
+ // Only clerk's documented stub lands — fully expanded, no `${...}`
169
+ // left — and nothing is written under the mixed-token name.
170
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["clerk.ts"]);
171
+ const stub = readFileSync(stubPath, "utf-8");
172
+ expect(stub).toBe(webhookStubFor("clerk"));
173
+ expect(stub).not.toContain("${");
174
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
175
+
176
+ // The whole call log is reads (`--version`, `list`); the vault file
177
+ // still parses to an empty object — a token-carrying name provokes
178
+ // no write.
179
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
180
+ expect(harness.callsTo("add")).toEqual([]);
181
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
182
+ });
183
+ });
@@ -0,0 +1,149 @@
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
+ describe("ai/phantom-wire known placeholder nested inside replacement text", () => {
14
+ let harness: Harness;
15
+ let dir: string;
16
+
17
+ beforeEach(() => {
18
+ harness = setupFakePhantom();
19
+ dir = mkdtempSync(join(tmpdir(), "wire-nested-placeholder-"));
20
+ });
21
+ afterEach(() => {
22
+ harness.cleanup();
23
+ rmSync(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ const recipe = (...names: string[]): Recipe => ({
27
+ id: "nested-placeholder",
28
+ query: "q",
29
+ createdAt: "2026-04-18T00:00:00.000Z",
30
+ providers: names.map((name) => ({ name })),
31
+ });
32
+
33
+ it.each([
34
+ "${provider}", // the name slot's own spelling
35
+ "${capitalize(provider)}", // the handler-name slot's spelling
36
+ "${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
37
+ "${signatureHeaderFor(provider)}", // the header slot's spelling
38
+ "${verificationHintFor(provider)}", // the hint slot's spelling
39
+ ])("never expands the inserted text a second time for the nested token %j", (slot) => {
40
+ // The name is ordinary text carrying one of the template's own slot
41
+ // spellings. This is the decisive re-expansion case: unlike an unknown
42
+ // `${...}` token, this one *would* resolve if the renderer ever fed
43
+ // inserted text back through the slots — a second pass would splice
44
+ // the whole name in again and double the flanking `pre`/`post` text
45
+ // (or recurse forever). The template literal evaluates each slot
46
+ // exactly once, so the carried token must arrive at every expansion
47
+ // site byte-for-byte, still seated between the name's own text.
48
+ const name = `pre${slot}post`;
49
+ const stub = webhookStubFor(name);
50
+
51
+ // Case-preserving sites emit the name — token plus surrounding text —
52
+ // byte-for-byte; `capitalize` folds only the first letter at the
53
+ // handler site.
54
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
55
+ expect(stub).toContain(`forge a ${name} webhook.`);
56
+ expect(stub).toContain(`handlePre${slot}postWebhook(req: Request)`);
57
+ // `webhookSecretKeyFor(provider)` uppercases the whole name — token
58
+ // included — at both reveal lines.
59
+ expect(
60
+ stub.split(`phantom reveal PRE${slot.toUpperCase()}POST_WEBHOOK_SECRET`).length - 1,
61
+ ).toBe(2);
62
+
63
+ // Five `${...}` spans — one per expansion site — every one the carried
64
+ // token in that site's case, none resolved away or duplicated.
65
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
66
+ expect(tokens).toHaveLength(5);
67
+ for (const token of tokens) expect(token.toLowerCase()).toBe(slot.toLowerCase());
68
+
69
+ // The no-second-pass proof: each token still sits between exactly one
70
+ // `pre` and one `post` (in whatever case that site's transform gives
71
+ // them), and the flanking text is never doubled. A re-resolve would
72
+ // have produced `prepre${...}postpost` at every site.
73
+ expect(stub.match(/pre\$\{[^}]*\}post/gi)).toHaveLength(5);
74
+ expect(stub).not.toMatch(/prepre|postpost/i);
75
+
76
+ // The remaining slots took their own generic values in the same
77
+ // single pass — the carried token never reached their switch cases.
78
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
79
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
80
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
81
+ });
82
+
83
+ it("emits the nested `${provider}` token literally at all five sites — line-for-line", () => {
84
+ // The sharpest form: the carried token spells the name slot itself, so
85
+ // at the name sites the emitted text is indistinguishable from an
86
+ // unresolved slot. Exact-line equality pins every site: the inserted
87
+ // text arrived verbatim, once, with no re-expansion splicing extra
88
+ // copies of the name around it.
89
+ const stub = webhookStubFor("pre${provider}post");
90
+
91
+ expect(stub).toContain(
92
+ "// Auto-generated by `stack apply` — webhook handler stub for pre${provider}post.",
93
+ );
94
+ expect(stub).toContain("// phantom reveal PRE${PROVIDER}POST_WEBHOOK_SECRET");
95
+ expect(stub).toContain(
96
+ "// export async function handlePre${provider}postWebhook(req: Request): Promise<Response> {",
97
+ );
98
+ expect(stub).toContain("// // to this endpoint can forge a pre${provider}post webhook.");
99
+ expect(stub).toContain(
100
+ "// // (`phantom reveal PRE${PROVIDER}POST_WEBHOOK_SECRET`) and compare in",
101
+ );
102
+
103
+ // Census: exactly five `${...}` spans — three in original case at the
104
+ // case-preserving sites, two uppercased at the reveal sites. A second
105
+ // pass would have re-resolved each `${provider}` into
106
+ // `pre${provider}post`, doubling the flanks; none appear.
107
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
108
+ expect(tokens).toEqual([
109
+ "${provider}",
110
+ "${PROVIDER}",
111
+ "${provider}",
112
+ "${provider}",
113
+ "${PROVIDER}",
114
+ ]);
115
+ expect(stub).not.toMatch(/prepre|postpost/i);
116
+ });
117
+
118
+ it("skips a nested-placeholder name byte-for-byte through wiring and writes nothing for it", async () => {
119
+ // Through `wirePhantomForRecipe` the nested-token name matches no
120
+ // catalog provider: it returns in `skipped` byte-for-byte — token and
121
+ // surrounding text intact — gets no stub on disk, and provokes no vault
122
+ // write. The known provider listed beside it wires normally, unaffected
123
+ // by the placeholder-shaped neighbour.
124
+ const name = "pre${provider}post";
125
+ const stubPath = join(dir, ".stack", "webhooks", "clerk.ts");
126
+ const result = await wirePhantomForRecipe(recipe(name, "clerk"), { cwd: dir });
127
+
128
+ expect(result).toEqual({
129
+ present: [],
130
+ missing: ["clerk:CLERK_SECRET_KEY"],
131
+ webhooks: [stubPath],
132
+ skipped: [name],
133
+ });
134
+ expect(result.skipped[0]).toBe(name);
135
+
136
+ // Only clerk's documented stub lands — nothing for the nested-token
137
+ // name, and the stub on disk is the same render `webhookStubFor`
138
+ // returns.
139
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["clerk.ts"]);
140
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("clerk"));
141
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
142
+
143
+ // The whole call log is reads (`--version`, `list`); the vault file
144
+ // still parses to an empty object.
145
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
146
+ expect(harness.callsTo("add")).toEqual([]);
147
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
148
+ });
149
+ });
@@ -0,0 +1,136 @@
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 newlineCount = (s: string) => s.split("\n").length - 1;
14
+
15
+ describe("ai/phantom-wire replacement text containing a newline", () => {
16
+ let harness: Harness;
17
+ let dir: string;
18
+
19
+ beforeEach(() => {
20
+ harness = setupFakePhantom();
21
+ dir = mkdtempSync(join(tmpdir(), "wire-newline-"));
22
+ });
23
+ afterEach(() => {
24
+ harness.cleanup();
25
+ rmSync(dir, { recursive: true, force: true });
26
+ });
27
+
28
+ const recipe = (...names: string[]): Recipe => ({
29
+ id: "newline",
30
+ query: "q",
31
+ createdAt: "2026-04-18T00:00:00.000Z",
32
+ providers: names.map((name) => ({ name })),
33
+ });
34
+
35
+ it("emits the literal newline byte at all five expansion sites", () => {
36
+ // A newline inside the replacement text is ordinary data: carried
37
+ // through the template's single pass it must arrive as a real 0x0A byte
38
+ // at every expansion site — not collapsed, not stripped, and not
39
+ // emitted as the two-character escape `\n`. Rendering the same letters
40
+ // without the break (`ab`) gives the flat baseline: the only difference
41
+ // between the two stubs is exactly five inserted line breaks, one per
42
+ // site that splices the name.
43
+ const stub = webhookStubFor("a\nb");
44
+ const flat = webhookStubFor("ab");
45
+
46
+ // Case-preserving sites emit the name byte-for-byte — newline included.
47
+ expect(stub).toContain("webhook handler stub for a\nb.");
48
+ expect(stub).toContain("forge a a\nb webhook.");
49
+ // `capitalize` folds only the first code point; the newline is untouched.
50
+ expect(stub).toContain("handleA\nbWebhook(req: Request)");
51
+ // `webhookSecretKeyFor` uppercases the letters around the break; it
52
+ // survives at both reveal lines.
53
+ expect(stub.split("phantom reveal A\nB_WEBHOOK_SECRET").length - 1).toBe(2);
54
+
55
+ // Exactly five extra line breaks over the flat render — one per
56
+ // expansion site, none eaten or doubled by the pass.
57
+ expect(newlineCount(stub)).toBe(newlineCount(flat) + 5);
58
+
59
+ // Byte-level pin: the character after `stub for a` is 0x0A itself, and
60
+ // no escaped `\n` two-character sequence or `${...}` residue appears
61
+ // anywhere in the output.
62
+ expect(stub.charCodeAt(stub.indexOf("stub for a") + "stub for a".length)).toBe(0x0a);
63
+ expect(stub).not.toContain("\\n");
64
+ expect(stub).not.toContain("${");
65
+
66
+ // Proof the byte is a real line break and not an escape: each site's
67
+ // tail continues on a fresh line with no `//` comment prefix.
68
+ expect(stub).toContain("\nb.\n");
69
+ expect(stub).toContain("\nbWebhook(req: Request): Promise<Response> {\n");
70
+ expect(stub).toContain("\nb webhook.\n");
71
+ expect(stub.split("\nB_WEBHOOK_SECRET").length - 1).toBe(2);
72
+
73
+ // The remaining slots resolved to their own generic values in the same
74
+ // pass — the newline-bearing name never reached their switch cases.
75
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
76
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
77
+ expect(verificationHintFor("a\nb")).toBe(verificationHintFor("zzq"));
78
+ });
79
+
80
+ it.each(["\nx", "x\n", "a\nb\nc", "\n", "a\n\nb"])(
81
+ "keeps every literal newline in %j at all five expansion sites",
82
+ (name) => {
83
+ // Leading, trailing, adjacent, and repeated breaks are the same data.
84
+ // `capitalize` and `toUpperCase` leave 0x0A untouched — a newline has
85
+ // no case mapping — so each break in the name lands once per
86
+ // expansion site and the count scales with the newline count.
87
+ const stub = webhookStubFor(name);
88
+ const flat = webhookStubFor(name.replace(/\n/g, ""));
89
+ const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
90
+
91
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
92
+ expect(stub).toContain(`forge a ${name} webhook.`);
93
+ expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
94
+ expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
95
+
96
+ // Five expansion sites carry the name; each preserves every newline
97
+ // in it. Escaped sequences would not raise the line count, and
98
+ // stripping would lower it below the flat baseline's own breaks.
99
+ expect(newlineCount(stub)).toBe(newlineCount(flat) + 5 * newlineCount(name));
100
+ expect(stub).not.toContain("\\n");
101
+ expect(stub).not.toContain("${");
102
+ },
103
+ );
104
+
105
+ it("skips a newline name byte-for-byte through wiring and writes nothing for it", async () => {
106
+ // Through `wirePhantomForRecipe` the newline-bearing name matches no
107
+ // catalog provider: it returns in `skipped` byte-for-byte — the 0x0A
108
+ // intact — gets no stub on disk, and provokes no vault write. The
109
+ // known provider listed beside it wires normally, unaffected by the
110
+ // line-break-shaped neighbour.
111
+ const name = "a\nb";
112
+ const stubPath = join(dir, ".stack", "webhooks", "clerk.ts");
113
+ const result = await wirePhantomForRecipe(recipe(name, "clerk"), { cwd: dir });
114
+
115
+ expect(result).toEqual({
116
+ present: [],
117
+ missing: ["clerk:CLERK_SECRET_KEY"],
118
+ webhooks: [stubPath],
119
+ skipped: [name],
120
+ });
121
+ expect(result.skipped[0]).toBe(name);
122
+ expect(result.skipped[0]?.charCodeAt(1)).toBe(0x0a);
123
+
124
+ // Only clerk's documented stub lands — nothing for the newline name,
125
+ // and the stub on disk is the same render `webhookStubFor` returns.
126
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["clerk.ts"]);
127
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("clerk"));
128
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
129
+
130
+ // The whole call log is reads (`--version`, `list`); the vault file
131
+ // still parses to an empty object.
132
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
133
+ expect(harness.callsTo("add")).toEqual([]);
134
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
135
+ });
136
+ });
@@ -0,0 +1,173 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+ import { chmodSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { __resetPhantomCache, __setPhantomSpawn } from "../phantom.ts";
7
+ import { wirePhantomForRecipe } from "./phantom-wire.ts";
8
+ import type { Recipe } from "./recipe.ts";
9
+
10
+ // Stubbed `phantom` subprocess: `--version` succeeds (unless `installed` is
11
+ // false) and `list --json` returns the names in `vault`.
12
+ let installed = true;
13
+ let vault: string[] | "unreadable" = [];
14
+
15
+ function fakeSpawn(_bin: string, args: string[]) {
16
+ const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
17
+ const out = new EventEmitter();
18
+ const err = new EventEmitter();
19
+ Object.assign(child, {
20
+ stdout: Object.assign(out, { destroy() {} }),
21
+ stderr: Object.assign(err, { destroy() {} }),
22
+ stdin: { on() {}, write() {}, end() {}, destroy() {} },
23
+ pid: undefined,
24
+ kill() {},
25
+ unref() {},
26
+ });
27
+ queueMicrotask(() => {
28
+ if (!installed) {
29
+ child.emit("error", Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
30
+ return;
31
+ }
32
+ if (args[0] === "list") {
33
+ if (vault === "unreadable") {
34
+ child.emit("close", 1, null);
35
+ return;
36
+ }
37
+ out.emit("data", Buffer.from(JSON.stringify(vault.map((name) => ({ name })))));
38
+ }
39
+ child.emit("close", 0, null);
40
+ });
41
+ return child;
42
+ }
43
+
44
+ function recipeOf(...names: string[]): Recipe {
45
+ return {
46
+ id: "order",
47
+ query: "order",
48
+ createdAt: "2026-04-18T00:00:00.000Z",
49
+ providers: names.map((name) => ({ name })),
50
+ };
51
+ }
52
+
53
+ describe("ai/phantom-wire result ordering with multiple providers", () => {
54
+ const savedPath = process.env.PATH;
55
+ let dir: string;
56
+
57
+ beforeEach(() => {
58
+ dir = realpathSync(mkdtempSync(join(tmpdir(), "wire-order-")));
59
+ // Binary only needs to resolve on PATH; spawn is stubbed. `.sh` target
60
+ // matches the sibling failing-subprocess test (macOS provenance policy).
61
+ writeFileSync(join(dir, "phantom.sh"), "#!/bin/sh\n");
62
+ chmodSync(join(dir, "phantom.sh"), 0o755);
63
+ symlinkSync(join(dir, "phantom.sh"), join(dir, "phantom"));
64
+ process.env.PATH = dir;
65
+ installed = true;
66
+ vault = [];
67
+ __resetPhantomCache();
68
+ __setPhantomSpawn(fakeSpawn as never);
69
+ });
70
+
71
+ afterEach(() => {
72
+ process.env.PATH = savedPath;
73
+ rmSync(dir, { recursive: true, force: true });
74
+ });
75
+
76
+ afterAll(() => {
77
+ __setPhantomSpawn();
78
+ __resetPhantomCache();
79
+ });
80
+
81
+ it("reports present and missing secrets in recipe order, then catalog secret order", async () => {
82
+ vault = ["OPENAI_API_KEY", "SUPABASE_ANON_KEY"];
83
+ const r = await wirePhantomForRecipe(recipeOf("stripe", "supabase", "openai", "github"), {
84
+ cwd: dir,
85
+ });
86
+ expect(r.present).toEqual(["supabase:SUPABASE_ANON_KEY", "openai:OPENAI_API_KEY"]);
87
+ expect(r.missing).toEqual([
88
+ "stripe:STRIPE_SECRET_KEY",
89
+ "supabase:SUPABASE_URL",
90
+ "supabase:SUPABASE_SERVICE_ROLE_KEY",
91
+ "github:GITHUB_TOKEN",
92
+ ]);
93
+ expect(r.skipped).toEqual([]);
94
+ });
95
+
96
+ it("follows input order, not alphabetical or catalog order", async () => {
97
+ const forward = await wirePhantomForRecipe(recipeOf("github", "stripe", "clerk"), { cwd: dir });
98
+ const reversed = await wirePhantomForRecipe(recipeOf("clerk", "stripe", "github"), {
99
+ cwd: dir,
100
+ });
101
+ const names = (r: { webhooks: string[] }) =>
102
+ r.webhooks.map((p) => p.split("/").pop()?.replace(/\.ts$/, ""));
103
+ expect(names(forward)).toEqual(["github", "stripe", "clerk"]);
104
+ expect(names(reversed)).toEqual(["clerk", "stripe", "github"]);
105
+ expect(forward.missing.map((m) => m.split(":")[0])).toEqual(["github", "stripe", "clerk"]);
106
+ expect(reversed.missing.map((m) => m.split(":")[0])).toEqual(["clerk", "stripe", "github"]);
107
+ });
108
+
109
+ it("keeps unknown providers in place in skipped and still orders the rest", async () => {
110
+ const r = await wirePhantomForRecipe(
111
+ recipeOf("zzz-unknown", "stripe", "aaa-unknown", "github"),
112
+ {
113
+ cwd: dir,
114
+ },
115
+ );
116
+ expect(r.skipped).toEqual(["zzz-unknown", "aaa-unknown"]);
117
+ expect(r.missing).toEqual(["stripe:STRIPE_SECRET_KEY", "github:GITHUB_TOKEN"]);
118
+ expect(r.webhooks.map((p) => p.split("/").pop())).toEqual(["stripe.ts", "github.ts"]);
119
+ });
120
+
121
+ it("marks every provider skipped, in input order, when the vault is unreadable", async () => {
122
+ vault = "unreadable";
123
+ const r = await wirePhantomForRecipe(recipeOf("github", "openai", "stripe"), { cwd: dir });
124
+ expect(r.skipped).toEqual(["github", "openai", "stripe"]);
125
+ expect(r.present).toEqual([]);
126
+ expect(r.missing).toEqual([]);
127
+ expect(r.webhooks.map((p) => p.split("/").pop())).toEqual(["github.ts", "stripe.ts"]);
128
+ });
129
+
130
+ it("lists skipped providers and webhook stubs in input order when phantom is missing", async () => {
131
+ installed = false;
132
+ const r = await wirePhantomForRecipe(recipeOf("stripe", "openai", "clerk", "github"), {
133
+ cwd: dir,
134
+ });
135
+ expect(r.skipped).toEqual(["stripe", "openai", "clerk", "github"]);
136
+ expect(r.webhooks.map((p) => p.split("/").pop())).toEqual([
137
+ "stripe.ts",
138
+ "clerk.ts",
139
+ "github.ts",
140
+ ]);
141
+ expect(r.present).toEqual([]);
142
+ expect(r.missing).toEqual([]);
143
+ });
144
+
145
+ it("repeats duplicate providers once per input entry, in order", async () => {
146
+ vault = ["STRIPE_SECRET_KEY"];
147
+ const r = await wirePhantomForRecipe(recipeOf("stripe", "github", "stripe"), { cwd: dir });
148
+ expect(r.present).toEqual(["stripe:STRIPE_SECRET_KEY", "stripe:STRIPE_SECRET_KEY"]);
149
+ expect(r.missing).toEqual(["github:GITHUB_TOKEN"]);
150
+ expect(r.webhooks.map((p) => p.split("/").pop())).toEqual([
151
+ "stripe.ts",
152
+ "github.ts",
153
+ "stripe.ts",
154
+ ]);
155
+ });
156
+
157
+ it("is deterministic across repeated runs of the same recipe", async () => {
158
+ vault = ["SENTRY_DSN", "RESEND_API_KEY"];
159
+ const recipe = recipeOf("sentry", "nope", "resend", "stripe", "supabase");
160
+ const first = await wirePhantomForRecipe(recipe, { cwd: dir });
161
+ for (let i = 0; i < 5; i++) {
162
+ expect(await wirePhantomForRecipe(recipe, { cwd: dir })).toEqual(first);
163
+ }
164
+ });
165
+
166
+ it("returns empty ordered lists for an empty recipe and for noWire", async () => {
167
+ const empty = { present: [], missing: [], webhooks: [], skipped: [] };
168
+ expect(await wirePhantomForRecipe(recipeOf(), { cwd: dir })).toEqual(empty);
169
+ expect(
170
+ await wirePhantomForRecipe(recipeOf("stripe", "github"), { cwd: dir, noWire: true }),
171
+ ).toEqual(empty);
172
+ });
173
+ });