@ashlr/stack-core 0.3.0 → 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 (55) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/__tests__/_harness.ts +65 -39
  4. package/src/__tests__/apply-placeholder-history.test.ts +55 -0
  5. package/src/__tests__/apply-placeholder-operator-checklist.test.ts +28 -0
  6. package/src/__tests__/apply-terminology-examples.test.ts +46 -0
  7. package/src/__tests__/config.test.ts +36 -0
  8. package/src/__tests__/credential-staging.test.ts +7 -6
  9. package/src/__tests__/deps.test.ts +35 -0
  10. package/src/__tests__/detect-source.test.ts +97 -0
  11. package/src/__tests__/fixtures/stack-manifest.v1.json +3 -3
  12. package/src/__tests__/phantom-process.test.ts +21 -3
  13. package/src/__tests__/phantom-wire-failing-subprocess.test.ts +126 -0
  14. package/src/__tests__/phantom-wire-repeated-placeholders.test.ts +84 -0
  15. package/src/__tests__/phantom-wire-unicode-combining.test.ts +100 -0
  16. package/src/__tests__/phantom-wire.test.ts +6329 -363
  17. package/src/__tests__/pipeline-integration.test.ts +26 -2
  18. package/src/__tests__/recommend-apply-e2e.test.ts +2 -2
  19. package/src/__tests__/secret-redaction.test.ts +13 -3
  20. package/src/ai/phantom-wire-adjacent-different-providers.test.ts +78 -0
  21. package/src/ai/phantom-wire-adjacent-known-placeholders.test.ts +266 -0
  22. package/src/ai/phantom-wire-args.test.ts +63 -0
  23. package/src/ai/phantom-wire-blank-lines.test.ts +174 -0
  24. package/src/ai/phantom-wire-carriage-return.test.ts +125 -0
  25. package/src/ai/phantom-wire-case-changed-placeholder.test.ts +209 -0
  26. package/src/ai/phantom-wire-closing-brace.test.ts +16 -0
  27. package/src/ai/phantom-wire-crlf.test.ts +48 -0
  28. package/src/ai/phantom-wire-dollar-amp.test.ts +128 -0
  29. package/src/ai/phantom-wire-double-quote.test.ts +135 -0
  30. package/src/ai/phantom-wire-empty-replacement.test.ts +122 -0
  31. package/src/ai/phantom-wire-empty-stderr.test.ts +145 -0
  32. package/src/ai/phantom-wire-empty-template.test.ts +81 -0
  33. package/src/ai/phantom-wire-empty-values.test.ts +129 -0
  34. package/src/ai/phantom-wire-errors.test.ts +296 -0
  35. package/src/ai/phantom-wire-known-placeholder-adjacent-punctuation.test.ts +188 -0
  36. package/src/ai/phantom-wire-known-placeholder-at-end.test.ts +160 -0
  37. package/src/ai/phantom-wire-known-placeholder.test.ts +170 -0
  38. package/src/ai/phantom-wire-malformed-json.test.ts +227 -0
  39. package/src/ai/phantom-wire-missing.test.ts +123 -0
  40. package/src/ai/phantom-wire-mixed-placeholders.test.ts +183 -0
  41. package/src/ai/phantom-wire-nested-placeholder.test.ts +149 -0
  42. package/src/ai/phantom-wire-newline.test.ts +136 -0
  43. package/src/ai/phantom-wire-order.test.ts +173 -0
  44. package/src/ai/phantom-wire-placeholder-two-lines.test.ts +196 -0
  45. package/src/ai/phantom-wire-repeated-known-placeholder.test.ts +241 -0
  46. package/src/ai/phantom-wire-repeated-unknown-placeholder.test.ts +55 -0
  47. package/src/ai/phantom-wire-replacement-dollar.test.ts +170 -0
  48. package/src/ai/phantom-wire-single-quote.test.ts +22 -0
  49. package/src/ai/phantom-wire-tab.test.ts +42 -0
  50. package/src/ai/phantom-wire-unknown-placeholder.test.ts +189 -0
  51. package/src/ai/phantom-wire-vault-name-case.test.ts +46 -0
  52. package/src/ai/phantom-wire.test.ts +144 -0
  53. package/src/ai/phantom-wire.ts +11 -4
  54. package/src/phantom.ts +10 -1
  55. package/src/stack-manifest.ts +3 -3
@@ -0,0 +1,125 @@
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
+ // Each of the template's own slot spellings, usable as a provider name that
14
+ // spells a known placeholder.
15
+ const SLOT_TOKENS = [
16
+ "${provider}", // the name slot's own spelling
17
+ "${capitalize(provider)}", // the handler-name slot's spelling
18
+ "${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
19
+ "${signatureHeaderFor(provider)}", // the header slot's spelling
20
+ "${verificationHintFor(provider)}", // the hint slot's spelling
21
+ ];
22
+
23
+ describe("ai/phantom-wire known placeholder beside a carriage return", () => {
24
+ let harness: Harness;
25
+ let dir: string;
26
+
27
+ beforeEach(() => {
28
+ harness = setupFakePhantom();
29
+ dir = mkdtempSync(join(tmpdir(), "wire-placeholder-cr-"));
30
+ });
31
+ afterEach(() => {
32
+ harness.cleanup();
33
+ rmSync(dir, { recursive: true, force: true });
34
+ });
35
+
36
+ const recipe = (...names: string[]): Recipe => ({
37
+ id: "placeholder-cr",
38
+ query: "q",
39
+ createdAt: "2026-04-18T00:00:00.000Z",
40
+ providers: names.map((name) => ({ name })),
41
+ });
42
+
43
+ it.each(SLOT_TOKENS.flatMap((token) => [`${token}\r`, `\r${token}`]))(
44
+ "replaces every slot and preserves the carriage return beside the carried token in %j",
45
+ (name) => {
46
+ // The name is exactly one known placeholder token with a carriage
47
+ // return seated beside it — before it or after it, no ordinary text.
48
+ // Expansion fills the five name-carrying sites with the name itself,
49
+ // so the rendered stub holds the token and the CR still adjacent in
50
+ // the input's own order: a resolver would resolve the token away, a
51
+ // sanitizer would strip or escape the control byte, a newline
52
+ // normalization would rewrite it as CRLF. None of that runs.
53
+ const stub = webhookStubFor(name);
54
+ const token = name.replace("\r", "");
55
+
56
+ // The three case-preserving sites carry the name byte-for-byte —
57
+ // token, CR and all. `capitalize` folds only the first code point,
58
+ // and both `$` and `\r` uppercase to themselves, so the handler site
59
+ // holds the identical bytes.
60
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
61
+ expect(stub).toContain(`forge a ${name} webhook.`);
62
+ expect(stub).toContain(`handle${name}Webhook(req: Request)`);
63
+ // The two `toUpperCase()` reveal sites carry the folded name; `\r`
64
+ // folds to itself, so the CR sits beside the folded token there too.
65
+ expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
66
+
67
+ // Five name sites, five carriage returns — none stripped, doubled or
68
+ // moved. Each sits exactly where the name put it: adjacent to the
69
+ // carried token, on the same side it occupied in the input.
70
+ expect(stub.split("\r").length - 1).toBe(5);
71
+ const adjacent = name.startsWith("\r") ? /\r\$\{[^}]*\}/g : /\$\{[^}]*\}\r/g;
72
+ expect(stub.match(adjacent)).toHaveLength(5);
73
+ // The byte is data, not a line ending: the stub still contains no
74
+ // CRLF, and the template's own `.\n` still terminates the title line
75
+ // right after the CR-carrying name.
76
+ expect(stub).not.toContain("\r\n");
77
+ expect(stub).toContain(`${name}.\n`);
78
+
79
+ // Every `${...}` in the output is the carried token in that site's
80
+ // case — five expansion sites, five occurrences, none resolved away.
81
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
82
+ expect(tokens).toHaveLength(5);
83
+ for (const t of tokens) expect(t.toLowerCase()).toBe(token.toLowerCase());
84
+
85
+ // The remaining slots took their own values in the same single pass:
86
+ // the generic signature header and the fallback hint — the carried
87
+ // token never reached their switch cases either.
88
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
89
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
90
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
91
+ },
92
+ );
93
+
94
+ it("skips a CR-carrying placeholder name byte-for-byte through wiring and writes nothing for it", async () => {
95
+ // Through `wirePhantomForRecipe` the name matches no catalog provider:
96
+ // it returns in `skipped` byte-for-byte — token and carriage return
97
+ // intact — gets no stub on disk, and provokes no vault write. The
98
+ // known provider listed beside it wires normally, unaffected by the
99
+ // control-byte neighbour.
100
+ const name = "${provider}\r";
101
+ const stubPath = join(dir, ".stack", "webhooks", "github.ts");
102
+ const result = await wirePhantomForRecipe(recipe(name, "github"), { cwd: dir });
103
+
104
+ expect(result).toEqual({
105
+ present: [],
106
+ missing: ["github:GITHUB_TOKEN"],
107
+ webhooks: [stubPath],
108
+ skipped: [name],
109
+ });
110
+ expect(result.skipped[0]).toBe(name);
111
+
112
+ // Only github's documented stub lands — nothing for the CR-carrying
113
+ // name, and the stub on disk is the same render `webhookStubFor`
114
+ // returns.
115
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["github.ts"]);
116
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("github"));
117
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
118
+
119
+ // The whole call log is reads (`--version`, `list`); the vault file
120
+ // still parses to an empty object.
121
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
122
+ expect(harness.callsTo("add")).toEqual([]);
123
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
124
+ });
125
+ });
@@ -0,0 +1,209 @@
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 case-changed placeholder name", () => {
16
+ let harness: Harness;
17
+ let dir: string;
18
+
19
+ beforeEach(() => {
20
+ harness = setupFakePhantom();
21
+ dir = mkdtempSync(join(tmpdir(), "wire-case-changed-"));
22
+ });
23
+ afterEach(() => {
24
+ harness.cleanup();
25
+ rmSync(dir, { recursive: true, force: true });
26
+ });
27
+
28
+ const recipe = (...names: string[]): Recipe => ({
29
+ id: "case-changed",
30
+ query: "q",
31
+ createdAt: "2026-04-18T00:00:00.000Z",
32
+ providers: names.map((name) => ({ name })),
33
+ });
34
+
35
+ it.each([
36
+ ["${Provider}", "${provider}"], // name slot, first letter up
37
+ ["${CAPITALIZE(provider)}", "${capitalize(provider)}"], // handler slot, callee uppercased
38
+ ["${webhooksecretkeyfor(provider)}", "${webhookSecretKeyFor(provider)}"], // key slot, all lower
39
+ ["${SignatureHeaderFor(Provider)}", "${signatureHeaderFor(provider)}"], // header slot, mixed
40
+ ["${VERIFICATIONHINTFOR(PROVIDER)}", "${verificationHintFor(provider)}"], // hint slot, all caps
41
+ ])(
42
+ "keeps the case-changed spelling %j literal at every site — never normalized to %j",
43
+ (name, slot) => {
44
+ // Placeholder matching is case-sensitive end to end: only the exact
45
+ // spelling names a slot. A whole-name token in any other case is plain
46
+ // data — it reaches all five expansion sites in its own case (or
47
+ // folded at the `toUpperCase()` sites), is never rewritten to the
48
+ // slot's exact spelling, and never resolves into the value the exact
49
+ // spelling would name. The template's real slots take their generic
50
+ // values around it.
51
+ const stub = webhookStubFor(name);
52
+
53
+ // The name lands byte-for-byte at the three case-preserving sites and
54
+ // whole-name uppercased at the two secret-key sites; `capitalize`
55
+ // folds only a leading letter and `$` uppercases to itself.
56
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
57
+ expect(stub).toContain(`forge a ${name} webhook.`);
58
+ expect(stub).toContain(`handle${name}Webhook(req: Request)`);
59
+ expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
60
+
61
+ // The exact-case slot spelling appears nowhere: the carried token was
62
+ // emitted verbatim or folded, never recognized and re-emitted in
63
+ // canonical slot case.
64
+ expect(stub).not.toContain(slot);
65
+
66
+ // Five `${...}` spans — one per expansion site — every one the
67
+ // carried token in one of the two casings the name legitimately
68
+ // takes; none resolved away, none minted in the slot's own case.
69
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
70
+ expect(tokens).toHaveLength(5);
71
+ for (const token of tokens) expect(token.toLowerCase()).toBe(name.toLowerCase());
72
+
73
+ // Unrelated text renders normally around the carried token: the
74
+ // signature-header and hint slots took the generic values the
75
+ // case-sensitive switches fall through to for a non-provider name.
76
+ expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
77
+ expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
78
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
79
+ },
80
+ );
81
+
82
+ it("keeps the ordinary text flanking a case-changed token byte-for-byte at every site", () => {
83
+ // `pre${Provider}post` seats a case-changed `${provider}`-lookalike
84
+ // between unrelated text. The flanks and the token's own case reach
85
+ // every expansion site untouched: the token is never folded to the
86
+ // exact slot spelling and the surrounding `pre`/`post` is never eaten,
87
+ // doubled, or merged into a value.
88
+ const name = "pre${Provider}post";
89
+ const stub = webhookStubFor(name);
90
+
91
+ // Case-preserving sites carry the whole name byte-for-byte;
92
+ // `capitalize` folds only the first letter; the two reveal sites fold
93
+ // token and flanks together.
94
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
95
+ expect(stub).toContain(`forge a ${name} webhook.`);
96
+ expect(stub).toContain("handlePre${Provider}postWebhook(req: Request)");
97
+ expect(count(stub, "phantom reveal PRE${PROVIDER}POST_WEBHOOK_SECRET")).toBe(2);
98
+
99
+ // Each token still sits between exactly one `pre` and one `post`, in
100
+ // whatever case that site's transform gives them — five sites, five
101
+ // seated occurrences — and never in the all-lowercase spelling an
102
+ // exact-match resolver would have used.
103
+ expect(stub.match(/pre\$\{provider\}post/gi)).toHaveLength(5);
104
+ expect(stub).not.toContain("pre${provider}post");
105
+ // `pre`+token+`post` verbatim at title and forge, `Pre`-folded at the
106
+ // handler site, fully folded at both reveal sites.
107
+ expect(count(stub, "pre${Provider}post")).toBe(2);
108
+ expect(count(stub, "Pre${Provider}post")).toBe(1);
109
+ expect(count(stub, "PRE${PROVIDER}POST")).toBe(2);
110
+
111
+ // The remaining slots took their generic values in the same single pass.
112
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
113
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
114
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
115
+ });
116
+
117
+ it.each(["Stripe", "STRIPE", "sTrIpE"])(
118
+ "misses every case-sensitive lookup for the case-changed provider name %s",
119
+ (name) => {
120
+ // `stripe` is a known provider only in its exact spelling. Changing
121
+ // any letter's case makes every match miss: the signature header and
122
+ // the verification hint fall through to their generic defaults
123
+ // instead of stripe's — the contract is byte equality, not folding.
124
+ const stub = webhookStubFor(name);
125
+ const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
126
+
127
+ // The name sites still carry the supplied case verbatim.
128
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
129
+ expect(stub).toContain(`forge a ${name} webhook.`);
130
+ expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
131
+
132
+ // The misses: generic header, generic hint — stripe's own values
133
+ // appear nowhere in the render.
134
+ expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
135
+ expect(stub).not.toContain("stripe-signature");
136
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
137
+ expect(stub).not.toContain("stripe.webhooks.constructEvent");
138
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
139
+
140
+ // Both reveal sites still spell `STRIPE_WEBHOOK_SECRET` — the default
141
+ // branch's whole-name `toUpperCase()`, not a match on stripe's case.
142
+ // Same bytes, different reason.
143
+ expect(count(stub, "phantom reveal STRIPE_WEBHOOK_SECRET")).toBe(2);
144
+ },
145
+ );
146
+
147
+ it("skips a case-changed provider name through wiring while the exact-case twin wires normally", async () => {
148
+ // `Stripe` differs from `stripe` by one letter's case. The catalog
149
+ // lookup and the webhook-provider set are case-sensitive: the variant
150
+ // returns in `skipped` byte-for-byte, writes no stub, provokes no vault
151
+ // write — while the exact-case name beside it wires normally.
152
+ const stubPath = join(dir, ".stack", "webhooks", "stripe.ts");
153
+ const result = await wirePhantomForRecipe(recipe("Stripe", "stripe"), { cwd: dir });
154
+
155
+ expect(result).toEqual({
156
+ present: [],
157
+ missing: ["stripe:STRIPE_SECRET_KEY"],
158
+ webhooks: [stubPath],
159
+ skipped: ["Stripe"],
160
+ });
161
+ // Byte-for-byte: the skipped name kept its case — not folded to `stripe`.
162
+ expect(result.skipped[0]).toBe("Stripe");
163
+
164
+ // Only stripe's documented stub lands — the directory listing holds
165
+ // exactly `stripe.ts` — and the stub on disk is the same render
166
+ // `webhookStubFor` returns. (`existsSync` can't stand in here: on a
167
+ // case-insensitive filesystem `Stripe.ts` resolves to `stripe.ts`, so
168
+ // the listing is the check that no second file was written.)
169
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["stripe.ts"]);
170
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("stripe"));
171
+
172
+ // The whole call log is reads (`--version`, `list`); the vault file
173
+ // still parses to an empty object.
174
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
175
+ expect(harness.callsTo("add")).toEqual([]);
176
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
177
+ });
178
+
179
+ it("skips a whole-name case-changed token byte-for-byte through wiring and writes nothing for it", async () => {
180
+ // Through `wirePhantomForRecipe` the case-changed slot spelling matches
181
+ // no catalog provider: it returns in `skipped` byte-for-byte — its case
182
+ // intact, not normalized to the exact spelling — gets no stub on disk,
183
+ // and provokes no vault write. The known provider listed beside it
184
+ // wires normally, unaffected by the placeholder-shaped neighbour.
185
+ const name = "${Provider}";
186
+ const stubPath = join(dir, ".stack", "webhooks", "github.ts");
187
+ const result = await wirePhantomForRecipe(recipe(name, "github"), { cwd: dir });
188
+
189
+ expect(result).toEqual({
190
+ present: [],
191
+ missing: ["github:GITHUB_TOKEN"],
192
+ webhooks: [stubPath],
193
+ skipped: [name],
194
+ });
195
+ expect(result.skipped[0]).toBe(name);
196
+
197
+ // Only github's documented stub lands — nothing for the token name —
198
+ // and the stub on disk is the same render `webhookStubFor` returns.
199
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["github.ts"]);
200
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("github"));
201
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
202
+
203
+ // The whole call log is reads (`--version`, `list`); the vault file
204
+ // still parses to an empty object.
205
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
206
+ expect(harness.callsTo("add")).toEqual([]);
207
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
208
+ });
209
+ });
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { webhookStubFor } from "./phantom-wire.ts";
3
+
4
+ describe("webhook stub closing braces", () => {
5
+ it("preserves a lone closing brace and its surrounding interpolated prose", () => {
6
+ const stub = webhookStubFor("before}after");
7
+
8
+ expect(stub.split("\n")[0]).toBe(
9
+ "// Auto-generated by `stack apply` — webhook handler stub for before}after.",
10
+ );
11
+ expect(stub).toContain("// // to this endpoint can forge a before}after webhook.\n");
12
+ expect(stub).toContain(
13
+ '// // parse, trust, or act on the body.\n// return new Response("ok", { status: 200 });\n// }\n',
14
+ );
15
+ });
16
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { verificationHintFor, webhookStubFor } from "./phantom-wire.ts";
3
+
4
+ const crlfCount = (s: string) => s.split("\r\n").length - 1;
5
+
6
+ describe("ai/phantom-wire replacement text containing CRLF", () => {
7
+ it("keeps the exact CRLF bytes at all five expansion sites", () => {
8
+ // CR+LF in the name is data: it must come out as the two bytes 0x0D 0x0A
9
+ // at every site, not normalized to LF, not split, not escaped.
10
+ const stub = webhookStubFor("a\r\nb");
11
+ const flat = webhookStubFor("ab");
12
+
13
+ expect(stub).toContain("webhook handler stub for a\r\nb.");
14
+ expect(stub).toContain("forge a a\r\nb webhook.");
15
+ expect(stub).toContain("handleA\r\nbWebhook(req: Request)");
16
+ expect(stub.split("phantom reveal A\r\nB_WEBHOOK_SECRET").length - 1).toBe(2);
17
+
18
+ // Template is LF-only, so every CRLF in the output is one we inserted.
19
+ expect(crlfCount(webhookStubFor("a\nb"))).toBe(0);
20
+ expect(crlfCount(stub)).toBe(5);
21
+ expect(stub.split("\r").length - 1).toBe(5);
22
+ expect(stub.split("\n").length - 1).toBe(flat.split("\n").length - 1 + 5);
23
+ expect(stub).not.toContain("\\r");
24
+ expect(stub).not.toContain("\\n");
25
+ expect(stub).not.toContain("${");
26
+
27
+ // Byte-level pin right after the first site.
28
+ const at = stub.indexOf("stub for a") + "stub for a".length;
29
+ expect([stub.charCodeAt(at), stub.charCodeAt(at + 1)]).toEqual([0x0d, 0x0a]);
30
+
31
+ expect(verificationHintFor("a\r\nb")).toBe(verificationHintFor("zzq"));
32
+ });
33
+
34
+ it.each(["\r\n", "x\r\n", "\r\nx", "a\r\n\r\nb", "a\n\rb"])(
35
+ "preserves every CR and LF byte of %j in order",
36
+ (name) => {
37
+ const stub = webhookStubFor(name);
38
+ const flat = webhookStubFor(name.replace(/[\r\n]/g, ""));
39
+ const count = (s: string, ch: string) => s.split(ch).length - 1;
40
+
41
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
42
+ expect(stub).toContain(`forge a ${name} webhook.`);
43
+ expect(count(stub, "\r")).toBe(count(flat, "\r") + 5 * count(name, "\r"));
44
+ expect(count(stub, "\n")).toBe(count(flat, "\n") + 5 * count(name, "\n"));
45
+ expect(crlfCount(stub)).toBe(crlfCount(flat) + 5 * crlfCount(name));
46
+ },
47
+ );
48
+ });
@@ -0,0 +1,128 @@
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 literal `$&` in replacement text", () => {
14
+ let harness: Harness;
15
+ let dir: string;
16
+
17
+ beforeEach(() => {
18
+ harness = setupFakePhantom();
19
+ dir = mkdtempSync(join(tmpdir(), "wire-dollar-amp-"));
20
+ });
21
+ afterEach(() => {
22
+ harness.cleanup();
23
+ rmSync(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ const recipe = (...names: string[]): Recipe => ({
27
+ id: "dollar-amp",
28
+ query: "q",
29
+ createdAt: "2026-04-18T00:00:00.000Z",
30
+ providers: names.map((name) => ({ name })),
31
+ });
32
+
33
+ it("keeps the bare `$&` match token as the whole name byte-for-byte at every site", () => {
34
+ // `$&` is the String.replace replacement pattern that splices in the
35
+ // matched substring — the slot spelling itself here. If expansion ever
36
+ // ran through replace, a whole name of `$&` would vanish into
37
+ // `${provider}` (or `${webhookSecretKeyFor(provider)}`, etc.) at every
38
+ // site: the two characters gone, placeholder-shaped residue in their
39
+ // place. Carried as data through a single template pass the name is
40
+ // just `$&` — and since neither `$` nor `&` has a case mapping, even
41
+ // the `capitalize` and `toUpperCase` sites emit the identical bytes.
42
+ const stub = webhookStubFor("$&");
43
+
44
+ expect(stub).toContain("webhook handler stub for $&.");
45
+ expect(stub).toContain("handle$&Webhook(req: Request)");
46
+ expect(stub).toContain("forge a $& webhook.");
47
+ expect(stub.split("phantom reveal $&_WEBHOOK_SECRET").length - 1).toBe(2);
48
+
49
+ // Five expansion sites, one `$&` each — none spliced into the match. A
50
+ // replace-driven render would show zero `$&` and `${...}` residue.
51
+ expect(stub.split("$&").length - 1).toBe(5);
52
+ expect(stub).not.toContain("${");
53
+
54
+ // The remaining slots resolved to their own values in the same pass:
55
+ // the generic signature header and the fallback hint.
56
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
57
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
58
+ expect(verificationHintFor("$&")).toBe(verificationHintFor("zzq"));
59
+ });
60
+
61
+ it.each(["$&hook", "hook$&", "$&$&", "a$&$&b"])(
62
+ "keeps every literal `$&` in %j at all five expansion sites",
63
+ (name) => {
64
+ // Leading, trailing, and adjacent tokens are the same data. Under
65
+ // replace semantics each `$&` would splice in the match — `a$&$&b`
66
+ // would come out `a${provider}${provider}b`. Rendered as a name every
67
+ // token survives at all five sites; the case folds touch only the
68
+ // letters around them, never the two characters themselves.
69
+ const stub = webhookStubFor(name);
70
+ const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
71
+
72
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
73
+ expect(stub).toContain(`forge a ${name} webhook.`);
74
+ expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
75
+ expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
76
+
77
+ // Each `$&` in the name lands once per expansion site — the count
78
+ // scales with the token count — and no `${...}` residue was spliced.
79
+ expect(stub.split("$&").length - 1).toBe(5 * (name.split("$&").length - 1));
80
+ expect(stub).not.toContain("${");
81
+ },
82
+ );
83
+
84
+ it("preserves `$&` after an astral initial at every expansion site", () => {
85
+ const stub = webhookStubFor("𐐨$&tail");
86
+
87
+ expect(stub).toContain("webhook handler stub for 𐐨$&tail.");
88
+ expect(stub).toContain("handle𐐀$&tailWebhook(req: Request)");
89
+ expect(stub).toContain("forge a 𐐨$&tail webhook.");
90
+ expect(stub.split("phantom reveal 𐐀$&TAIL_WEBHOOK_SECRET")).toHaveLength(3);
91
+ expect(stub.split("$&")).toHaveLength(6);
92
+ });
93
+
94
+ it("skips `$&` names byte-for-byte through wiring and writes nothing for them", async () => {
95
+ // Through `wirePhantomForRecipe` the bare and doubled tokens match no
96
+ // catalog provider: both return in `skipped` byte-for-byte — every `$`
97
+ // and `&` intact, in order — get no stub on disk, and provoke no vault
98
+ // write. The known provider listed beside them wires normally,
99
+ // unaffected by the match-token-shaped neighbours.
100
+ const stubPath = join(dir, ".stack", "webhooks", "clerk.ts");
101
+ const result = await wirePhantomForRecipe(recipe("$&", "$&$&", "clerk"), { cwd: dir });
102
+
103
+ expect(result).toEqual({
104
+ present: [],
105
+ missing: ["clerk:CLERK_SECRET_KEY"],
106
+ webhooks: [stubPath],
107
+ skipped: ["$&", "$&$&"],
108
+ });
109
+ // Byte-level pin: `$` is 0x24 and `&` is 0x26 — the pair survives the
110
+ // catalog lookup and the skip path unconsumed.
111
+ expect(result.skipped[0]?.charCodeAt(0)).toBe(0x24);
112
+ expect(result.skipped[0]?.charCodeAt(1)).toBe(0x26);
113
+ expect(result.skipped[1]?.split("$&").length - 1).toBe(2);
114
+
115
+ // Only clerk's documented stub lands — nothing for the `$&` names, and
116
+ // the stub on disk is the same render `webhookStubFor` returns.
117
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["clerk.ts"]);
118
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("clerk"));
119
+ expect(existsSync(join(dir, ".stack", "webhooks", "$&.ts"))).toBe(false);
120
+ expect(existsSync(join(dir, ".stack", "webhooks", "$&$&.ts"))).toBe(false);
121
+
122
+ // The whole call log is reads (`--version`, `list`); the vault file
123
+ // still parses to an empty object.
124
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
125
+ expect(harness.callsTo("add")).toEqual([]);
126
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
127
+ });
128
+ });
@@ -0,0 +1,135 @@
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 double quote in replacement text", () => {
14
+ let harness: Harness;
15
+ let dir: string;
16
+
17
+ beforeEach(() => {
18
+ harness = setupFakePhantom();
19
+ dir = mkdtempSync(join(tmpdir(), "wire-double-quote-"));
20
+ });
21
+ afterEach(() => {
22
+ harness.cleanup();
23
+ rmSync(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ const recipe = (...names: string[]): Recipe => ({
27
+ id: "double-quote",
28
+ query: "q",
29
+ createdAt: "2026-04-18T00:00:00.000Z",
30
+ providers: names.map((name) => ({ name })),
31
+ });
32
+
33
+ // The template carries its own quotes: two around the fixed signature
34
+ // header in `req.headers.get("...")`, two in `new Response("Missing
35
+ // signature", ...)` and two in `new Response("ok", ...)`. Measure them off
36
+ // a quote-free name so the arithmetic tracks template drift instead of
37
+ // pinning a count by hand.
38
+ const templateDoubles = webhookStubFor("zzq").split('"').length - 1;
39
+
40
+ it.each([
41
+ 'pay"hook', // interior quote
42
+ '"hook', // leading quote
43
+ 'hook"', // trailing quote
44
+ 'a""b', // adjacent pair inside the name
45
+ '"', // the entire name is one quote
46
+ '""', // the entire name is a quote pair
47
+ ])("renders the double quote byte-for-byte at every expansion site for %j", (name) => {
48
+ const stub = webhookStubFor(name);
49
+ const nameQuotes = name.split('"').length - 1;
50
+
51
+ // Raw sites: the title line and the forgery warning carry the name
52
+ // unchanged — the quote is data, never escaped, encoded, or dropped.
53
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
54
+ expect(stub).toContain(`forge a ${name} webhook.`);
55
+
56
+ // `capitalize(name)` site: only the first code point folds; `"` has no
57
+ // case mapping, so every quote in the name survives into the handler.
58
+ expect(stub).toContain(
59
+ `handle${name[0]?.toUpperCase() ?? ""}${name.slice(1)}Webhook(req: Request)`,
60
+ );
61
+
62
+ // `webhookSecretKeyFor(name)` sites: `toUpperCase()` leaves `"` alone,
63
+ // so the quote-bearing key lands in both `phantom reveal` lines.
64
+ expect(stub.split(`phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`).length - 1).toBe(2);
65
+
66
+ // Five expansion sites per name; the stub holds exactly the template's
67
+ // own quotes plus the ones the name carried in — none lost to escaping.
68
+ expect(stub.split('"').length - 1).toBe(templateDoubles + nameQuotes * 5);
69
+ });
70
+
71
+ it.each(['pay"hook', '"', 'a""b'])(
72
+ "emits no escaped or encoded form of the quote for %j",
73
+ (name) => {
74
+ const stub = webhookStubFor(name);
75
+ // A JSON.stringify-style pass would emit `\"`; an HTML/URI encoder
76
+ // would emit an entity or %22. None of those run: the quote is
77
+ // interpolated as plain text inside line comments.
78
+ for (const encoded of ['\\"', """, """, """, "%22", "\\u0022"]) {
79
+ expect(stub).not.toContain(encoded);
80
+ }
81
+ },
82
+ );
83
+
84
+ it("keeps every quote inside `//` comments and the template's own string literals intact", () => {
85
+ // The only real string literals in the stub are the template's own:
86
+ // a `"` reaching one of those positions would corrupt the generated
87
+ // code's syntax. Every provider-name site sits in a line comment, so
88
+ // the carried quote can never terminate a literal — and the literals
89
+ // themselves keep exactly their template text.
90
+ const stub = webhookStubFor('pay"hook');
91
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
92
+ expect(stub).toContain('new Response("Missing signature", { status: 400 })');
93
+ expect(stub).toContain('new Response("ok", { status: 200 })');
94
+ expect(stub.split("\n").every((line) => line === "" || line.startsWith("//"))).toBe(true);
95
+ });
96
+
97
+ it("returns the fallback hint unchanged for a quote-bearing name", () => {
98
+ // The hint is a fixed string per provider with no name interpolation:
99
+ // the quote in the replacement text never reaches it.
100
+ expect(verificationHintFor('pay"hook')).toBe(
101
+ "Verify per the provider's documented signature scheme.",
102
+ );
103
+ expect(verificationHintFor('"')).toBe(verificationHintFor("zzq"));
104
+ });
105
+
106
+ it("skips a quote-bearing name byte-for-byte through wiring and writes nothing for it", async () => {
107
+ // `stripe"` matches no catalog provider (the lookup compares exact
108
+ // bytes): it returns in `skipped` with U+0022 intact, gets no stub on
109
+ // disk, and provokes no vault write. The known provider listed beside
110
+ // it wires normally, unaffected by the quoted neighbour.
111
+ const name = 'stripe"';
112
+ const stubPath = join(dir, ".stack", "webhooks", "github.ts");
113
+ const result = await wirePhantomForRecipe(recipe(name, "github"), { cwd: dir });
114
+
115
+ expect(result).toEqual({
116
+ present: [],
117
+ missing: ["github:GITHUB_TOKEN"],
118
+ webhooks: [stubPath],
119
+ skipped: [name],
120
+ });
121
+ expect(result.skipped[0]?.charCodeAt(6)).toBe(0x22);
122
+
123
+ // Only github's documented stub lands — nothing for the quoted name —
124
+ // and the stub on disk is the same render `webhookStubFor` returns.
125
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["github.ts"]);
126
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("github"));
127
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
128
+
129
+ // The whole call log is reads (`--version`, `list`); the vault file
130
+ // still parses to an empty object.
131
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
132
+ expect(harness.callsTo("add")).toEqual([]);
133
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
134
+ });
135
+ });