@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,196 @@
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
+ const newlineCount = (s: string) => count(s, "\n");
15
+
16
+ // Each of the template's own slot spellings, usable as a provider name that
17
+ // spells a known placeholder.
18
+ const SLOT_TOKENS = [
19
+ "${provider}", // the name slot's own spelling
20
+ "${capitalize(provider)}", // the handler-name slot's spelling
21
+ "${webhookSecretKeyFor(provider)}", // the secret-key slot's spelling
22
+ "${signatureHeaderFor(provider)}", // the header slot's spelling
23
+ "${verificationHintFor(provider)}", // the hint slot's spelling
24
+ ];
25
+
26
+ describe("ai/phantom-wire known placeholder on each of two lines", () => {
27
+ let harness: Harness;
28
+ let dir: string;
29
+
30
+ beforeEach(() => {
31
+ harness = setupFakePhantom();
32
+ dir = mkdtempSync(join(tmpdir(), "wire-two-line-placeholder-"));
33
+ });
34
+ afterEach(() => {
35
+ harness.cleanup();
36
+ rmSync(dir, { recursive: true, force: true });
37
+ });
38
+
39
+ const recipe = (...names: string[]): Recipe => ({
40
+ id: "two-line-placeholder",
41
+ query: "q",
42
+ createdAt: "2026-04-18T00:00:00.000Z",
43
+ providers: names.map((name) => ({ name })),
44
+ });
45
+
46
+ it.each(SLOT_TOKENS)(
47
+ "carries both occurrences of %j and keeps the newline between them at every site",
48
+ (token) => {
49
+ // The name is exactly two lines, each holding the same known
50
+ // placeholder — the token on the first line, a newline, the token
51
+ // again on the second, no ordinary text. Both occurrences are data:
52
+ // expansion fills the five name-carrying sites with the name itself,
53
+ // so each site receives the pair in the input's own order with the
54
+ // line break between them. A resolver would resolve the tokens away,
55
+ // a sanitizer would strip or escape the 0x0A, line-ending
56
+ // normalization would rewrite it as CRLF. None of that runs.
57
+ const name = `${token}\n${token}`;
58
+ const stub = webhookStubFor(name);
59
+ const flat = webhookStubFor(`${token}${token}`);
60
+
61
+ // The three case-preserving sites carry the name byte-for-byte —
62
+ // both tokens and the break between them. `capitalize` folds only
63
+ // the first code point, and `$` uppercases to itself, so the handler
64
+ // site holds the identical bytes.
65
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
66
+ expect(stub).toContain(`forge a ${name} webhook.`);
67
+ expect(stub).toContain(`handle${name}Webhook(req: Request)`);
68
+ // The two `toUpperCase()` reveal sites carry the folded name; `\n`
69
+ // has no case mapping, so the folded pair sits line-broken there too.
70
+ expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
71
+
72
+ // Both occurrences land at every site, each on its own line and in
73
+ // order: the exact-case pair at the three case-preserving sites, the
74
+ // folded pair at both reveal sites.
75
+ expect(count(stub, `${token}\n${token}`)).toBe(3);
76
+ expect(count(stub, `${token.toUpperCase()}\n${token.toUpperCase()}`)).toBe(2);
77
+
78
+ // The intervening byte is a real 0x0A — not an escape, not a CRLF.
79
+ // The first token closes its line (token then `\n`) and the second
80
+ // opens the next (`\n${`, no `//` prefix) at all five sites. Rendering
81
+ // the same pair on one line gives the flat baseline: the only
82
+ // difference is exactly five inserted line breaks, one per site.
83
+ const at = stub.indexOf(`${token}\n${token}`);
84
+ expect(stub.charCodeAt(at + token.length)).toBe(0x0a);
85
+ expect(count(stub, `${token}\n`)).toBe(3);
86
+ expect(count(stub, `${token.toUpperCase()}\n`)).toBe(2);
87
+ expect(count(stub, "\n${")).toBe(5);
88
+ expect(newlineCount(stub)).toBe(newlineCount(flat) + 5);
89
+ expect(stub).not.toContain("\\n");
90
+ expect(stub).not.toContain("\r");
91
+
92
+ // Ten `${...}` spans — two per site × five sites — every one the
93
+ // carried token in that site's case: six exact-case, four folded.
94
+ // Resolution would erase them, re-expansion would mint more, and
95
+ // nothing reorders the pair onto one line.
96
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
97
+ expect(tokens).toHaveLength(10);
98
+ for (const t of tokens) expect(t.toLowerCase()).toBe(token.toLowerCase());
99
+ expect(count(stub, token)).toBe(6);
100
+ expect(count(stub, token.toUpperCase())).toBe(4);
101
+
102
+ // The sibling slots took their own generic values in the same single
103
+ // pass — the carried pair never reached their switch cases either.
104
+ expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
105
+ expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
106
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
107
+ },
108
+ );
109
+
110
+ it("keeps a different known token on each line in line order at every site", () => {
111
+ // Two lines, a DIFFERENT known placeholder on each — `${provider}` on
112
+ // the first, `${signatureHeaderFor(provider)}` on the second, ordinary
113
+ // `pre`/`post` text at the name's edges. Both tokens ride along in the
114
+ // name's own order: the first-line token never drops to the second
115
+ // line and the second-line token never climbs to the first.
116
+ const first = "${provider}";
117
+ const second = "${signatureHeaderFor(provider)}";
118
+ const name = `pre${first}\n${second}post`;
119
+ const stub = webhookStubFor(name);
120
+ const flat = webhookStubFor(`pre${first}${second}post`);
121
+
122
+ // Case-preserving sites carry the name byte-for-byte; `capitalize`
123
+ // folds only the leading `p` → `Pre`, and the reveal sites fold the
124
+ // whole name — tokens, text, but not the newline.
125
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
126
+ expect(stub).toContain(`forge a ${name} webhook.`);
127
+ expect(stub).toContain(`handlePre${first}\n${second}postWebhook(req: Request)`);
128
+ expect(
129
+ count(
130
+ stub,
131
+ `phantom reveal PRE${first.toUpperCase()}\n${second.toUpperCase()}POST_WEBHOOK_SECRET`,
132
+ ),
133
+ ).toBe(2);
134
+
135
+ // In order, on their own lines: exact-case at the three case-preserving
136
+ // sites, folded at both reveal sites — never transposed, never
137
+ // flattened onto one line, in either casing.
138
+ expect(count(stub, `${first}\n${second}`)).toBe(3);
139
+ expect(count(stub, `${first.toUpperCase()}\n${second.toUpperCase()}`)).toBe(2);
140
+ expect(stub).not.toContain(`${second}\n${first}`);
141
+ expect(stub).not.toContain(`${second.toUpperCase()}\n${first.toUpperCase()}`);
142
+ expect(newlineCount(stub)).toBe(newlineCount(flat) + 5);
143
+
144
+ // Ten spans — each is one of the two tokens in that site's case, still
145
+ // seated where the name put it: the `pre` text ahead of the first
146
+ // line's token, the `post` text behind the second line's.
147
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
148
+ expect(tokens).toHaveLength(10);
149
+ for (const t of tokens.map((t) => t.toLowerCase())) {
150
+ expect([first.toLowerCase(), second.toLowerCase()]).toContain(t);
151
+ }
152
+ expect(stub.match(/pre\$\{[^}]*\}\n/gi)).toHaveLength(5);
153
+ expect(stub.match(/\n\$\{[^}]*\}post/gi)).toHaveLength(5);
154
+
155
+ // The sibling slots resolved to their own generic values in the same
156
+ // single pass — the carried tokens never reached their switch cases.
157
+ expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
158
+ expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
159
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
160
+ });
161
+
162
+ it("skips a two-line placeholder name byte-for-byte through wiring and writes nothing for it", async () => {
163
+ // Through `wirePhantomForRecipe` the name matches no catalog provider:
164
+ // it returns in `skipped` byte-for-byte — both tokens and the newline
165
+ // between them intact — gets no stub on disk, and provokes no vault
166
+ // write. The known provider listed beside it wires normally,
167
+ // unaffected by the line-broken placeholder neighbour.
168
+ const name = "${provider}\n${provider}";
169
+ const stubPath = join(dir, ".stack", "webhooks", "clerk.ts");
170
+ const result = await wirePhantomForRecipe(recipe(name, "clerk"), { cwd: dir });
171
+
172
+ expect(result).toEqual({
173
+ present: [],
174
+ missing: ["clerk:CLERK_SECRET_KEY"],
175
+ webhooks: [stubPath],
176
+ skipped: [name],
177
+ });
178
+ expect(result.skipped[0]).toBe(name);
179
+ expect(result.skipped[0]?.charCodeAt("${provider}".length)).toBe(0x0a);
180
+
181
+ // Only clerk's documented stub lands — nothing for the two-line name,
182
+ // and the stub on disk is the same render `webhookStubFor` returns.
183
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["clerk.ts"]);
184
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("clerk"));
185
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
186
+
187
+ // The whole call log is reads (`--version`, `list`); the vault file
188
+ // still parses to an empty object.
189
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
190
+ expect(harness.callsTo("add")).toEqual([]);
191
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
192
+
193
+ // The hint is a fixed string: a line-broken name never leaks into it.
194
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
195
+ });
196
+ });
@@ -0,0 +1,241 @@
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 repeated known placeholder in a provider name", () => {
16
+ let harness: Harness;
17
+ let dir: string;
18
+
19
+ beforeEach(() => {
20
+ harness = setupFakePhantom();
21
+ dir = mkdtempSync(join(tmpdir(), "wire-repeated-known-"));
22
+ });
23
+ afterEach(() => {
24
+ harness.cleanup();
25
+ rmSync(dir, { recursive: true, force: true });
26
+ });
27
+
28
+ const recipe = (...names: string[]): Recipe => ({
29
+ id: "repeated-known",
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
+ ])("carries both occurrences of the repeated token %j to every expansion site", (slot) => {
42
+ // The name is ordinary text carrying the SAME template-slot spelling
43
+ // twice — a repeated known placeholder. Both occurrences are data, not
44
+ // slots: unlike a non-global `.replace`, which would touch only the
45
+ // first, every occurrence must be carried with the name to all five
46
+ // expansion sites (three case-preserving, one first-letter-folded, two
47
+ // whole-name-uppercased), and none may resolve to the value its
48
+ // spelling names — there is no second pass.
49
+ const name = `pre${slot}mid${slot}post`;
50
+ const stub = webhookStubFor(name);
51
+
52
+ // The name — both repeats included — lands byte-for-byte at the three
53
+ // case-preserving sites and whole-name uppercased at both secret-key
54
+ // sites. `capitalize` folds only the first letter: `pre` → `Pre`.
55
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
56
+ expect(stub).toContain(`forge a ${name} webhook.`);
57
+ expect(stub).toContain(`handlePre${slot}mid${slot}postWebhook(req: Request)`);
58
+ expect(
59
+ count(
60
+ stub,
61
+ `phantom reveal PRE${slot.toUpperCase()}MID${slot.toUpperCase()}POST_WEBHOOK_SECRET`,
62
+ ),
63
+ ).toBe(2);
64
+
65
+ // Census: two repeats per site × five sites = ten `${...}` spans, every
66
+ // one the carried token in that site's case — six exact-case at the
67
+ // case-preserving sites, four uppercased at the reveal sites. None
68
+ // resolved away, none dropped, none duplicated.
69
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
70
+ expect(tokens).toHaveLength(10);
71
+ for (const token of tokens) expect(token.toLowerCase()).toBe(slot.toLowerCase());
72
+ expect(count(stub, slot)).toBe(6);
73
+ expect(count(stub, slot.toUpperCase())).toBe(4);
74
+
75
+ // Per-occurrence proof: at every occurrence of the token — both repeats,
76
+ // all sites — the immediate left flank is the name's `pre`/`mid` and the
77
+ // right flank the name's `mid`/`post` (the `$` of the sibling token for
78
+ // the first repeat). Each occurrence received identical carry-through;
79
+ // no occurrence was resolved to a value or reseated next to spliced text.
80
+ for (const [variant, sites] of [
81
+ [slot, 3],
82
+ [slot.toUpperCase(), 2],
83
+ ] as const) {
84
+ const contexts: string[] = [];
85
+ for (let at = stub.indexOf(variant); at !== -1; at = stub.indexOf(variant, at + 1)) {
86
+ const left = stub.slice(at - 3, at);
87
+ const right = stub.slice(at + variant.length, at + variant.length + 4);
88
+ contexts.push(`${left}|${right}`.toLowerCase());
89
+ }
90
+ const expected = Array.from({ length: sites }, () => ["pre|mid$", "mid|post"]).flat();
91
+ expect(contexts.sort()).toEqual(expected.sort());
92
+ }
93
+
94
+ // A second pass would have re-resolved each carried token into the whole
95
+ // name again, doubling the flanking text; no flank doubles anywhere.
96
+ expect(stub.match(/pre\$\{[^}]*\}mid\$\{[^}]*\}post/gi)).toHaveLength(5);
97
+ expect(stub).not.toMatch(/prepre|midmid|postpost/i);
98
+
99
+ // The sibling slots took their own generic values in the same single
100
+ // pass — the repeated token never reached their switch cases either.
101
+ expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
102
+ expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
103
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
104
+ });
105
+
106
+ it("emits a repeated `${provider}` token at the name's edges literally at all five sites — line-for-line", () => {
107
+ // The sharpest form: the repeated token spells the name slot itself and
108
+ // sits at BOTH edges of the name, so at the name sites the emitted text
109
+ // is indistinguishable from unfilled slots — and `capitalize`'s fold of
110
+ // the first code point lands on `$`, which uppercases to itself. Exact
111
+ // lines pin every site: both repeats arrived verbatim, once each.
112
+ const name = "${provider}and${provider}";
113
+ const stub = webhookStubFor(name);
114
+
115
+ expect(stub).toContain(
116
+ "// Auto-generated by `stack apply` — webhook handler stub for ${provider}and${provider}.\n",
117
+ );
118
+ expect(stub).toContain("// phantom reveal ${PROVIDER}AND${PROVIDER}_WEBHOOK_SECRET");
119
+ expect(stub).toContain(
120
+ "// export async function handle${provider}and${provider}Webhook(req: Request): Promise<Response> {",
121
+ );
122
+ expect(stub).toContain(
123
+ "// // to this endpoint can forge a ${provider}and${provider} webhook.",
124
+ );
125
+ expect(stub).toContain(
126
+ "// // (`phantom reveal ${PROVIDER}AND${PROVIDER}_WEBHOOK_SECRET`) and compare in",
127
+ );
128
+
129
+ // Census: ten `${...}` spans — both repeats at each site, in site order:
130
+ // title, reveal, handler, forge (all case-preserving), reveal (folded).
131
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
132
+ expect(tokens).toEqual([
133
+ "${provider}",
134
+ "${provider}",
135
+ "${PROVIDER}",
136
+ "${PROVIDER}",
137
+ "${provider}",
138
+ "${provider}",
139
+ "${provider}",
140
+ "${provider}",
141
+ "${PROVIDER}",
142
+ "${PROVIDER}",
143
+ ]);
144
+ expect(count(stub, "${provider}")).toBe(6);
145
+ expect(count(stub, "${PROVIDER}")).toBe(4);
146
+
147
+ // No re-expansion: a second pass would splice the name into each carried
148
+ // token again, producing `and${provider}and` chains; the `and` flank
149
+ // stays single everywhere.
150
+ expect(stub.match(/\$\{provider\}and\$\{provider\}/g)).toHaveLength(3);
151
+ expect(stub.match(/\$\{PROVIDER\}AND\$\{PROVIDER\}/g)).toHaveLength(2);
152
+ expect(stub).not.toMatch(/andand/i);
153
+
154
+ // Sibling slots resolved to their own values, once each.
155
+ expect(count(stub, 'req.headers.get("x-webhook-signature")')).toBe(1);
156
+ expect(count(stub, "// // Verify per the provider's documented signature scheme.")).toBe(1);
157
+ });
158
+
159
+ it("carries three repeats of the same known token — every occurrence at every site", () => {
160
+ // Repeat count generalizes: three occurrences of the same slot spelling
161
+ // each ride along as data — fifteen spans total, nine exact-case, six
162
+ // folded — and every occurrence sits between the name's own flanks.
163
+ const slot = "${signatureHeaderFor(provider)}";
164
+ const name = `a${slot}b${slot}c${slot}d`;
165
+ const stub = webhookStubFor(name);
166
+
167
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
168
+ expect(stub).toContain(`forge a ${name} webhook.`);
169
+ expect(stub).toContain(`handleA${slot}b${slot}c${slot}dWebhook(req: Request)`);
170
+ expect(
171
+ count(
172
+ stub,
173
+ `phantom reveal A${slot.toUpperCase()}B${slot.toUpperCase()}C${slot.toUpperCase()}D_WEBHOOK_SECRET`,
174
+ ),
175
+ ).toBe(2);
176
+
177
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
178
+ expect(tokens).toHaveLength(15);
179
+ for (const token of tokens) expect(token.toLowerCase()).toBe(slot.toLowerCase());
180
+ expect(count(stub, slot)).toBe(9);
181
+ expect(count(stub, slot.toUpperCase())).toBe(6);
182
+
183
+ // Every occurrence is flanked by the letters the name supplied — `a|b`,
184
+ // `b|c`, `c|d` in order — three of each per case-preserving site, two of
185
+ // each per folded site. None resolved, reordered, or reseated.
186
+ for (const [variant, sites] of [
187
+ [slot, 3],
188
+ [slot.toUpperCase(), 2],
189
+ ] as const) {
190
+ const contexts: string[] = [];
191
+ for (let at = stub.indexOf(variant); at !== -1; at = stub.indexOf(variant, at + 1)) {
192
+ const left = stub.slice(at - 1, at);
193
+ const right = stub.slice(at + variant.length, at + variant.length + 1);
194
+ contexts.push(`${left}|${right}`.toLowerCase());
195
+ }
196
+ const expected = Array.from({ length: sites }, () => ["a|b", "b|c", "c|d"]).flat();
197
+ expect(contexts.sort()).toEqual(expected.sort());
198
+ }
199
+
200
+ // A second pass would have re-resolved each carried token into the whole
201
+ // name again, minting extra spans and doubling the flanks; the complete
202
+ // a<tok>b<tok>c<tok>d shape appears exactly once per site and the census
203
+ // above already counted every `${...}` span there is.
204
+ expect(stub.match(/a\$\{[^}]*\}b\$\{[^}]*\}c\$\{[^}]*\}d/gi)).toHaveLength(5);
205
+ });
206
+
207
+ it("skips a repeated-placeholder name byte-for-byte through wiring and writes nothing for it", async () => {
208
+ // Through `wirePhantomForRecipe` the repeated-token name matches no
209
+ // catalog provider: it returns in `skipped` byte-for-byte — both
210
+ // occurrences intact — gets no stub on disk, and provokes no vault
211
+ // write. The known provider listed beside it wires normally, unaffected
212
+ // by the placeholder-shaped neighbour.
213
+ const name = "pre${provider}mid${provider}post";
214
+ const stubPath = join(dir, ".stack", "webhooks", "github.ts");
215
+ const result = await wirePhantomForRecipe(recipe(name, "github"), { cwd: dir });
216
+
217
+ expect(result).toEqual({
218
+ present: [],
219
+ missing: ["github:GITHUB_TOKEN"],
220
+ webhooks: [stubPath],
221
+ skipped: [name],
222
+ });
223
+ expect(result.skipped[0]).toBe(name);
224
+
225
+ // Only github's documented stub lands — nothing for the repeated-token
226
+ // name, and the stub on disk is the same render `webhookStubFor`
227
+ // returns.
228
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["github.ts"]);
229
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("github"));
230
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
231
+
232
+ // The whole call log is reads (`--version`, `list`); the vault file
233
+ // still parses to an empty object.
234
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
235
+ expect(harness.callsTo("add")).toEqual([]);
236
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
237
+
238
+ // The hint is a fixed string: a repeated-token name never leaks into it.
239
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
240
+ });
241
+ });
@@ -0,0 +1,55 @@
1
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { mkdtempSync, 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 { webhookStubFor } from "./phantom-wire.ts";
7
+
8
+ setDefaultTimeout(30_000);
9
+
10
+ describe("ai/phantom-wire repeated unknown placeholders", () => {
11
+ let harness: Harness;
12
+
13
+ beforeEach(() => {
14
+ harness = setupFakePhantom();
15
+ });
16
+ afterEach(() => {
17
+ harness.cleanup();
18
+ });
19
+
20
+ it("keeps both occurrences of repeated unknown placeholders unchanged at every expansion site", () => {
21
+ // A provider name carrying two distinct unknown placeholders. Each must
22
+ // survive all five expansion sites in the stub unchanged (three case-
23
+ // preserving, two uppercased for the reveal lines). The expansion sites
24
+ // are: the title line, the forge line, the handler name (first-letter
25
+ // capitalized), and the two `phantom reveal` lines (uppercased).
26
+ const name = "pre${ALPHA}mid${BETA}post";
27
+
28
+ const stub = webhookStubFor(name);
29
+
30
+ // Three case-preserving sites: the placeholders keep their original case.
31
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
32
+ expect(stub).toContain("forge a pre${ALPHA}mid${BETA}post webhook.");
33
+ expect(stub).toContain("handlePre${ALPHA}mid${BETA}postWebhook(req: Request)");
34
+
35
+ // Two uppercased reveal lines: both placeholders are folded with the
36
+ // surrounding text by `toUpperCase()`, but neither is resolved or lost.
37
+ expect(stub.split("phantom reveal PRE${ALPHA}MID${BETA}POST_WEBHOOK_SECRET").length - 1).toBe(
38
+ 2,
39
+ );
40
+
41
+ // Every `${...}` token in the stub is one of the two carried placeholders
42
+ // — five expansion sites × two tokens = ten occurrences.
43
+ const tokens = [...stub.matchAll(/\$\{[^}]*\}/g)].map((m) => m[0]);
44
+ expect(tokens).toHaveLength(10);
45
+
46
+ // Five occurrences of each token (one per expansion site), each still
47
+ // seated between the surrounding text.
48
+ expect(stub.split("${ALPHA}").length - 1).toBe(5);
49
+ expect(stub.split("${BETA}").length - 1).toBe(5);
50
+
51
+ // Lowercased reveal lines: both placeholders folded together.
52
+ expect(stub.toLowerCase().split("${alpha}").length - 1).toBe(5);
53
+ expect(stub.toLowerCase().split("${beta}").length - 1).toBe(5);
54
+ });
55
+ });
@@ -0,0 +1,170 @@
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 $-substitution patterns in replacement text", () => {
16
+ let harness: Harness;
17
+ let dir: string;
18
+
19
+ beforeEach(() => {
20
+ harness = setupFakePhantom();
21
+ dir = mkdtempSync(join(tmpdir(), "wire-dollar-replacement-"));
22
+ });
23
+ afterEach(() => {
24
+ harness.cleanup();
25
+ rmSync(dir, { recursive: true, force: true });
26
+ });
27
+
28
+ const recipe = (...names: string[]): Recipe => ({
29
+ id: "dollar-replacement",
30
+ query: "q",
31
+ createdAt: "2026-04-18T00:00:00.000Z",
32
+ providers: names.map((name) => ({ name })),
33
+ });
34
+
35
+ /**
36
+ * The stub a literal renderer must produce for `name`. The provider name
37
+ * is replacement text: `$1`, `$$`, `$&`, `` $` `` and `$'` are directives
38
+ * to `String.prototype.replace` — a `.replace`-based renderer would splice
39
+ * a capture group in for `$1`, collapse `$$` to one dollar, inject the
40
+ * matched slot for `$&`, or inject the template text around the match for
41
+ * `` $` ``/`$'`. Template literals have no substitution pattern at all, so
42
+ * the `$` bytes must arrive as inert data.
43
+ *
44
+ * The oracle renders a sentinel name — the same letters with each `$x`
45
+ * swapped for NUL, a byte with no special meaning in either syntax — then
46
+ * substitutes each NUL back to its pattern through a replacer FUNCTION,
47
+ * whose return value is also exempt from `$`-pattern interpretation.
48
+ * Whole-stub equality against this oracle catches every replace-semantics
49
+ * failure anywhere in the output, not just at the five expansion sites.
50
+ */
51
+ const literalRender = (name: string): string => {
52
+ const patterns = [...name.matchAll(/\$./g)].map((m) => m[0]);
53
+ const sentinel = name.replaceAll(/\$./g, "\u0000");
54
+ // Each expansion site replays the name's pattern sequence in order, so
55
+ // the i-th NUL maps to patterns[i mod patterns.length].
56
+ let i = 0;
57
+ return webhookStubFor(sentinel).replaceAll("\u0000", () => patterns[i++ % patterns.length]);
58
+ };
59
+
60
+ it.each(["svc$1pay", "$1", "pay$1", "a$1$2$9z"])(
61
+ "inserts the $1-carrying name %j literally at every site — the characters, not a capture group",
62
+ (name) => {
63
+ const stub = webhookStubFor(name);
64
+ const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
65
+
66
+ // The decisive differential: byte-for-byte equal to the sentinel
67
+ // render — the `$`-pattern bytes behaved exactly like inert data at
68
+ // every expansion site and everywhere between them.
69
+ expect(stub).toBe(literalRender(name));
70
+
71
+ // The characters appear literally in the result: the name at the
72
+ // three case-preserving sites, first-letter-folded at the handler
73
+ // site, whole-name-folded at both reveal sites.
74
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
75
+ expect(stub).toContain(`forge a ${name} webhook.`);
76
+ expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
77
+ expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
78
+
79
+ // Dollar census: only the `$` the name itself carries, times the five
80
+ // sites — none dropped by a `$$` collapse or minted by a splice — and
81
+ // every `$1` still seated inside the name's own bytes.
82
+ expect(count(stub, "$")).toBe(count(name, "$") * 5);
83
+ expect(count(stub, "$1")).toBe(count(name, "$1") * 5);
84
+ expect(stub).not.toContain("${");
85
+
86
+ // The sibling slots resolved to their own values in the same pass —
87
+ // the pattern never reached their switch cases.
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.each(["svc$$pay", "svc$&pay", "svc$`pay", "svc$'pay"])(
95
+ "inserts the replace-directive name %j literally at every site",
96
+ (name) => {
97
+ // Same scenario for the rest of the replace-pattern family: `$$`
98
+ // collapses to `$`, `$&` splices the match, `` $` ``/`$'` splice the
99
+ // template text flanking the match. All are `toUpperCase`-invariant,
100
+ // so the sentinel differential applies unchanged.
101
+ const stub = webhookStubFor(name);
102
+ const capitalized = `${name[0].toUpperCase()}${name.slice(1)}`;
103
+
104
+ expect(stub).toBe(literalRender(name));
105
+
106
+ expect(stub).toContain(`webhook handler stub for ${name}.`);
107
+ expect(stub).toContain(`forge a ${name} webhook.`);
108
+ expect(stub).toContain(`handle${capitalized}Webhook(req: Request)`);
109
+ expect(count(stub, `phantom reveal ${name.toUpperCase()}_WEBHOOK_SECRET`)).toBe(2);
110
+
111
+ // Two `$` for `$$`, one for the rest — all carried, never resolved.
112
+ expect(count(stub, "$")).toBe(count(name, "$") * 5);
113
+ expect(stub).not.toContain("${");
114
+
115
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
116
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
117
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
118
+ },
119
+ );
120
+
121
+ it("inserts the named-capture pattern `$<ref>` literally, folding only its letters at the reveal sites", () => {
122
+ // `$<name>` splices a named capture group under replace semantics. Its
123
+ // letters fold with the rest of the name at the `toUpperCase()` sites,
124
+ // so the differential oracle does not apply — assert the sites and the
125
+ // census directly.
126
+ const name = "svc$<ref>pay";
127
+ const stub = webhookStubFor(name);
128
+
129
+ expect(count(stub, "svc$<ref>pay")).toBe(2); // title + forge
130
+ expect(stub).toContain("handleSvc$<ref>payWebhook(req: Request)");
131
+ expect(count(stub, "phantom reveal SVC$<REF>PAY_WEBHOOK_SECRET")).toBe(2);
132
+ expect(count(stub, "$")).toBe(5);
133
+ expect(stub).not.toContain("${");
134
+
135
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
136
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
137
+ expect(verificationHintFor(name)).toBe(verificationHintFor("zzq"));
138
+ });
139
+
140
+ it("skips a $1-carrying name byte-for-byte through wiring and writes nothing for it", async () => {
141
+ // Through `wirePhantomForRecipe` the `$1` name matches no catalog
142
+ // provider: it returns in `skipped` byte-for-byte — the capture-group
143
+ // spelling intact — gets no stub on disk, and provokes no vault write.
144
+ // The known provider listed beside it wires normally, unaffected by the
145
+ // replace-pattern-shaped neighbour.
146
+ const name = "svc$1pay";
147
+ const stubPath = join(dir, ".stack", "webhooks", "stripe.ts");
148
+ const result = await wirePhantomForRecipe(recipe(name, "stripe"), { cwd: dir });
149
+
150
+ expect(result).toEqual({
151
+ present: [],
152
+ missing: ["stripe:STRIPE_SECRET_KEY"],
153
+ webhooks: [stubPath],
154
+ skipped: [name],
155
+ });
156
+ expect(result.skipped[0]).toBe(name);
157
+
158
+ // Only stripe's documented stub lands — nothing for the `$1` name, and
159
+ // the stub on disk is the same render `webhookStubFor` returns.
160
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["stripe.ts"]);
161
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("stripe"));
162
+ expect(existsSync(join(dir, ".stack", "webhooks", `${name}.ts`))).toBe(false);
163
+
164
+ // The whole call log is reads (`--version`, `list`); the vault file
165
+ // still parses to an empty object.
166
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
167
+ expect(harness.callsTo("add")).toEqual([]);
168
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
169
+ });
170
+ });