@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,126 @@
1
+ import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdtempSync,
7
+ realpathSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ type Script = (args: string[]) => {
16
+ code?: number;
17
+ stdout?: string;
18
+ stderr?: string;
19
+ error?: string;
20
+ };
21
+ let script: Script = () => ({ code: 0 });
22
+ const calls: string[][] = [];
23
+
24
+ function fakeSpawn(_bin: string, args: string[]) {
25
+ calls.push(args);
26
+ const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
27
+ const out = new EventEmitter();
28
+ const err = new EventEmitter();
29
+ Object.assign(child, {
30
+ stdout: Object.assign(out, { destroy() {} }),
31
+ stderr: Object.assign(err, { destroy() {} }),
32
+ stdin: { on() {}, write() {}, end() {}, destroy() {} },
33
+ pid: undefined,
34
+ kill() {},
35
+ unref() {},
36
+ });
37
+ queueMicrotask(() => {
38
+ const r = script(args);
39
+ if (r.error) {
40
+ child.emit("error", Object.assign(new Error(r.error), { code: r.error }));
41
+ return;
42
+ }
43
+ if (r.stdout) out.emit("data", Buffer.from(r.stdout));
44
+ if (r.stderr) err.emit("data", Buffer.from(r.stderr));
45
+ child.emit("close", r.code ?? 0, null);
46
+ });
47
+ return child;
48
+ }
49
+
50
+ import { wirePhantomForRecipe } from "../ai/phantom-wire.ts";
51
+ import { __resetPhantomCache, __setPhantomSpawn } from "../phantom.ts";
52
+
53
+ afterAll(() => {
54
+ __setPhantomSpawn();
55
+ __resetPhantomCache();
56
+ });
57
+
58
+ describe("ai/phantom-wire with a stubbed failing subprocess", () => {
59
+ let dir: string;
60
+ const recipe = {
61
+ id: "stripe-only",
62
+ query: "payments",
63
+ createdAt: "2026-04-18T00:00:00.000Z",
64
+ providers: [{ name: "stripe" }, { name: "neon" }],
65
+ };
66
+
67
+ const savedPath = process.env.PATH;
68
+
69
+ beforeEach(() => {
70
+ dir = realpathSync(mkdtempSync(join(tmpdir(), "wire-stub-")));
71
+ // The binary is only resolved on PATH, never executed: spawn is stubbed.
72
+ // Link a `.sh` target like _harness.ts: macOS provenance policy rejects
73
+ // an extensionless file written by this process.
74
+ writeFileSync(join(dir, "phantom.sh"), "#!/bin/sh\n");
75
+ chmodSync(join(dir, "phantom.sh"), 0o755);
76
+ symlinkSync(join(dir, "phantom.sh"), join(dir, "phantom"));
77
+ process.env.PATH = dir;
78
+ calls.length = 0;
79
+ __resetPhantomCache();
80
+ __setPhantomSpawn(fakeSpawn as never);
81
+ });
82
+ afterEach(() => {
83
+ process.env.PATH = savedPath;
84
+ rmSync(dir, { recursive: true, force: true });
85
+ });
86
+
87
+ it("skips every provider, still writes the stub, and never writes the vault when `phantom list` exits non-zero", async () => {
88
+ script = (args) =>
89
+ args[0] === "--version"
90
+ ? { code: 0, stdout: "phantom 0.7.9\n" }
91
+ : { code: 1, stderr: "vault locked" };
92
+
93
+ const result = await wirePhantomForRecipe(recipe, { cwd: dir });
94
+
95
+ expect(result).toEqual({
96
+ present: [],
97
+ missing: [],
98
+ webhooks: [join(dir, ".stack/webhooks/stripe.ts")],
99
+ skipped: ["stripe", "neon"],
100
+ });
101
+ expect(existsSync(join(dir, ".stack/webhooks/stripe.ts"))).toBe(true);
102
+ expect(calls).toEqual([["--version"], ["list", "--json"]]);
103
+ });
104
+
105
+ it("treats a spawn ENOENT as Phantom missing: skips providers and writes stubs", async () => {
106
+ script = () => ({ error: "ENOENT" });
107
+
108
+ const result = await wirePhantomForRecipe(recipe, { cwd: dir });
109
+
110
+ expect(result.skipped).toEqual(["stripe", "neon"]);
111
+ expect(result.present).toEqual([]);
112
+ expect(result.missing).toEqual([]);
113
+ expect(result.webhooks).toEqual([join(dir, ".stack/webhooks/stripe.ts")]);
114
+ expect(calls).toEqual([["--version"]]);
115
+ });
116
+
117
+ it("skips providers when `phantom list --json` prints unparseable output", async () => {
118
+ script = (args) => (args[0] === "--version" ? { code: 0 } : { code: 0, stdout: "not json" });
119
+
120
+ const result = await wirePhantomForRecipe(recipe, { cwd: dir });
121
+
122
+ expect(result.skipped).toEqual(["stripe", "neon"]);
123
+ expect(result.webhooks).toHaveLength(1);
124
+ expect(calls.every((a) => a[0] !== "add")).toBe(true);
125
+ });
126
+ });
@@ -0,0 +1,84 @@
1
+ import { expect, it } from "bun:test";
2
+ import { webhookStubFor } from "../ai/phantom-wire.ts";
3
+
4
+ it.each([
5
+ ["stripe", "STRIPE_WEBHOOK_SECRET"],
6
+ ["clerk", "CLERK_WEBHOOK_SECRET"],
7
+ ["supabase", "SUPABASE_WEBHOOK_SECRET"],
8
+ ["github", "GITHUB_WEBHOOK_SECRET"],
9
+ ])("fills every repeated placeholder for %s", (provider, secret) => {
10
+ const stub = webhookStubFor(provider);
11
+ const lines = stub.split("\n");
12
+
13
+ expect(lines.filter((line) => line.includes("webhook handler stub for"))).toEqual([
14
+ `// Auto-generated by \`stack apply\` — webhook handler stub for ${provider}.`,
15
+ ]);
16
+ expect(lines.filter((line) => line.includes("webhook."))).toEqual([
17
+ `// // to this endpoint can forge a ${provider} webhook.`,
18
+ ]);
19
+ expect(lines.filter((line) => line.includes("phantom reveal"))).toEqual([
20
+ `// phantom reveal ${secret}`,
21
+ `// // (\`phantom reveal ${secret}\`) and compare in`,
22
+ ]);
23
+ expect(stub).not.toContain("${");
24
+ });
25
+
26
+ it("preserves a single quote after a dollar sign in replacement text", () => {
27
+ const stub = webhookStubFor("pay$'co");
28
+ const lines = stub.split("\n");
29
+
30
+ expect(lines).toContain("// Auto-generated by `stack apply` — webhook handler stub for pay$'co.");
31
+ expect(lines).toContain("// // to this endpoint can forge a pay$'co webhook.");
32
+ expect(lines).toContain(
33
+ "// export async function handlePay$'coWebhook(req: Request): Promise<Response> {",
34
+ );
35
+ expect(lines.filter((line) => line.includes("phantom reveal"))).toEqual([
36
+ "// phantom reveal PAY$'CO_WEBHOOK_SECRET",
37
+ "// // (`phantom reveal PAY$'CO_WEBHOOK_SECRET`) and compare in",
38
+ ]);
39
+ });
40
+
41
+ it("preserves a forward slash in replacement text at every provider site", () => {
42
+ const lines = webhookStubFor("pay/co").split("\n");
43
+
44
+ expect(lines).toContain("// Auto-generated by `stack apply` — webhook handler stub for pay/co.");
45
+ expect(lines).toContain("// // to this endpoint can forge a pay/co webhook.");
46
+ expect(lines).toContain(
47
+ "// export async function handlePay/coWebhook(req: Request): Promise<Response> {",
48
+ );
49
+ expect(lines.filter((line) => line.includes("phantom reveal"))).toEqual([
50
+ "// phantom reveal PAY/CO_WEBHOOK_SECRET",
51
+ "// // (`phantom reveal PAY/CO_WEBHOOK_SECRET`) and compare in",
52
+ ]);
53
+ });
54
+
55
+ it("preserves the capture-group reference $1 literally in replacement text at every provider site", () => {
56
+ // `$1` is a directive to `String.prototype.replace` — a replace-based
57
+ // renderer would splice capture group 1 in place of these characters.
58
+ // The template literal inserts the provider name as inert data, so the
59
+ // two characters arrive byte-for-byte at every expansion site.
60
+ const lines = webhookStubFor("pay$1co").split("\n");
61
+
62
+ expect(lines).toContain("// Auto-generated by `stack apply` — webhook handler stub for pay$1co.");
63
+ expect(lines).toContain("// // to this endpoint can forge a pay$1co webhook.");
64
+ expect(lines).toContain(
65
+ "// export async function handlePay$1coWebhook(req: Request): Promise<Response> {",
66
+ );
67
+ expect(lines.filter((line) => line.includes("phantom reveal"))).toEqual([
68
+ "// phantom reveal PAY$1CO_WEBHOOK_SECRET",
69
+ "// // (`phantom reveal PAY$1CO_WEBHOOK_SECRET`) and compare in",
70
+ ]);
71
+ });
72
+
73
+ it.each([
74
+ ["payco", "PAYCO_WEBHOOK_SECRET"],
75
+ ["pay$'co", "PAY$'CO_WEBHOOK_SECRET"],
76
+ ])("expands the leading provider slot and preserves its suffix for %s", (provider, expected) => {
77
+ // The fallback secret-name template starts with the provider interpolation,
78
+ // followed immediately by the literal _WEBHOOK_SECRET suffix.
79
+ const revealLines = webhookStubFor(provider)
80
+ .split("\n")
81
+ .filter((line) => line.startsWith("// phantom reveal "));
82
+
83
+ expect(revealLines).toEqual([`// phantom reveal ${expected}`]);
84
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { verificationHintFor, webhookStubFor } from "../ai/phantom-wire.ts";
3
+
4
+ /**
5
+ * Helper: return the sequence of code point values in a string.
6
+ */
7
+ function codePoints(str: string): number[] {
8
+ return [...str].map((ch) => ch.codePointAt(0) as number);
9
+ }
10
+
11
+ describe("webhookStubFor — replacement value with Unicode combining character", () => {
12
+ // "café" spelled as base "e" + U+0301 (combining acute accent) = 5 code points.
13
+ // This is the NFD (decomposed) form; NFC would fuse e + U+0301 into é (U+00E9).
14
+ const name = "cafe\u0301";
15
+ const expectedCodePoints = [99, 97, 102, 101, 769]; // c, a, f, e, ́
16
+
17
+ it("preserves the original code points in every expansion site", () => {
18
+ const stub = webhookStubFor(name);
19
+ expect(stub).not.toContain("${");
20
+
21
+ // 1. The plain provider name (5 code points, not normalized to NFC).
22
+ const plainLines = stub.split("\n").filter((l) => l.includes("webhook handler stub for"));
23
+ expect(plainLines).toHaveLength(1);
24
+ const plainMatch = plainLines[0].match(/stub for (cafe\u0301)\./);
25
+ expect(plainMatch).not.toBeNull();
26
+ expect(codePoints(plainMatch![1])).toEqual(expectedCodePoints);
27
+
28
+ // 2. The capitalized provider name — first character upper-cased,
29
+ // remaining code points (including the combining character) intact.
30
+ // "café" → "Café" = C, a, f, e, U+0301
31
+ const capExpected = [67, 97, 102, 101, 769]; // C, a, f, e, ́
32
+ const capLines = stub.split("\n").filter((l) => l.includes("handleCaf"));
33
+ expect(capLines).toHaveLength(1);
34
+ // Extract the function name: "handleCaféWebhook"
35
+ const fnMatch = capLines[0].match(/handle(Cafe\u0301)Webhook/);
36
+ expect(fnMatch).not.toBeNull();
37
+ expect(codePoints(fnMatch![1])).toEqual(capExpected);
38
+
39
+ // 3. The secret key name — uppercased provider + _WEBHOOK_SECRET.
40
+ // toUpperCase() on the combining mark (U+0301) returns itself.
41
+ const secretExpected = "CAFE\u0301_WEBHOOK_SECRET";
42
+ const revealLines = stub.split("\n").filter((l) => l.includes("phantom reveal"));
43
+ expect(revealLines).toHaveLength(2);
44
+ for (const line of revealLines) {
45
+ expect(line).toContain(secretExpected);
46
+ }
47
+
48
+ // 4. The forge line — plain provider name again.
49
+ const forgeLines = stub.split("\n").filter((l) => l.includes("forge a"));
50
+ expect(forgeLines).toHaveLength(1);
51
+ const forgeMatch = forgeLines[0].match(/forge a (cafe\u0301) webhook/);
52
+ expect(forgeMatch).not.toBeNull();
53
+ expect(codePoints(forgeMatch![1])).toEqual(expectedCodePoints);
54
+ });
55
+
56
+ it("does not NFC-normalize the combining character", () => {
57
+ // NFC normalization would fuse e + U+0301 into é (U+00E9), changing 5
58
+ // code points to 4. The rendered result must keep the original 5.
59
+ const stub = webhookStubFor(name);
60
+ // The stub must contain the decomposed sequence (length 5), not the
61
+ // precomposed é (U+00E9).
62
+ expect(stub).toContain(name);
63
+ expect(stub).not.toContain("café"); // NFC form with precomposed é
64
+ // Capitalized decomposed: C + a + f + e + U+0301
65
+ expect(stub).toContain("Cafe\u0301Webhook");
66
+ });
67
+
68
+ it("yields the same code point count in the rendered stub as in the input", () => {
69
+ // The input name has 5 code points; every occurrence in the rendered
70
+ // stub (plain, capitalized, uppercase) must also have 5 code points.
71
+ const stub = webhookStubFor(name);
72
+ const inputLength = name.length; // 5 (iterable characters = code points)
73
+ expect(inputLength).toBe(5);
74
+
75
+ // Extract each expansion from the stub and verify the length.
76
+ const plainMatch = stub.match(/stub for (cafe\u0301)\./);
77
+ expect(plainMatch![1].length).toBe(inputLength);
78
+
79
+ const capMatch = stub.match(/handle(Cafe\u0301)Webhook/);
80
+ expect(capMatch![1].length).toBe(inputLength);
81
+
82
+ const secretMatch = stub.match(/phantom reveal (CAFE\u0301_WEBHOOK_SECRET)/);
83
+ // The secret is the uppercased name + _WEBHOOK_SECRET, so the name
84
+ // portion still has 5 code points.
85
+ const secretNamePart = secretMatch![1].replace("_WEBHOOK_SECRET", "");
86
+ expect(secretNamePart.length).toBe(inputLength);
87
+ });
88
+ });
89
+
90
+ describe("verificationHintFor — provider with combining character", () => {
91
+ it("returns the generic fallback without interpolating the provider name", () => {
92
+ // An unknown provider (including one with combining characters) hits
93
+ // the default branch, which returns a fixed string with no placeholders.
94
+ const name = "cafe\u0301";
95
+ const hint = verificationHintFor(name);
96
+ expect(hint).toBe("Verify per the provider's documented signature scheme.");
97
+ expect(hint).not.toContain(name);
98
+ expect(hint).not.toContain("${");
99
+ });
100
+ });