@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,122 @@
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 name whose only byte has no case mapping: `capitalize` folds it to itself
16
+ // and `toUpperCase` leaves it, so it lands byte-identically at all five
17
+ // name-fed expansion sites — while still exercising the normal codePointAt
18
+ // path, since the `s === ""` early return only fires for the truly empty name.
19
+ const SENTINEL = "\u0000";
20
+
21
+ describe("ai/phantom-wire known placeholder replaced with an empty value", () => {
22
+ let harness: Harness;
23
+ let dir: string;
24
+
25
+ beforeEach(() => {
26
+ harness = setupFakePhantom();
27
+ dir = mkdtempSync(join(tmpdir(), "wire-empty-replacement-"));
28
+ });
29
+ afterEach(() => {
30
+ harness.cleanup();
31
+ rmSync(dir, { recursive: true, force: true });
32
+ });
33
+
34
+ const recipe = (...names: string[]): Recipe => ({
35
+ id: "empty-replacement",
36
+ query: "q",
37
+ createdAt: "2026-04-18T00:00:00.000Z",
38
+ providers: names.map((name) => ({ name })),
39
+ });
40
+
41
+ it("replaces the known placeholder with the empty string and leaves all surrounding text byte-for-byte", () => {
42
+ // Whole-stub differential oracle: the sentinelled render marks every
43
+ // expansion site, and deleting each mark must reproduce the empty render
44
+ // exactly — the strongest form of "surrounding text remains", covering
45
+ // every byte of the template, not just the lines the placeholder sits on.
46
+ const sent = webhookStubFor(SENTINEL);
47
+
48
+ // The sentinel reached exactly five sites — one per name-fed slot — so
49
+ // the oracle below is not vacuous: every placeholder was exercised.
50
+ expect(count(sent, SENTINEL)).toBe(5);
51
+
52
+ // Removing the placeholder's replacement value leaves the empty render:
53
+ // the slot emitted "" and nothing else moved anywhere in the output.
54
+ expect(sent.replaceAll(SENTINEL, "")).toBe(webhookStubFor(""));
55
+ });
56
+
57
+ it("emits the empty replacement at all five sites with each surrounding line intact", () => {
58
+ // Full-line equality (not substring presence): each placeholder line is
59
+ // byte-exact — the slot vanished into "" and the rest of the line kept
60
+ // its fixed text with no extra residue appended to it.
61
+ const stub = webhookStubFor("");
62
+ const lines = stub.split("\n");
63
+
64
+ // `${provider}` sites: the name vanished between the fixed prose — a
65
+ // trailing period after "for " and a doubled space inside "forge a".
66
+ expect(lines).toContain("// Auto-generated by `stack apply` — webhook handler stub for .");
67
+ expect(lines).toContain("// // to this endpoint can forge a webhook.");
68
+
69
+ // `${capitalize(provider)}` site: the `s === ""` early return keeps
70
+ // `codePointAt(0)`'s undefined out of `String.fromCodePoint`, which
71
+ // would throw a RangeError on NaN — the handler name is just "handle".
72
+ expect(lines).toContain(
73
+ "// export async function handleWebhook(req: Request): Promise<Response> {",
74
+ );
75
+
76
+ // `${webhookSecretKeyFor(provider)}` sites: `"".toUpperCase()` is "" —
77
+ // the default switch case emitted the bare `_WEBHOOK_SECRET` suffix at
78
+ // both reveal lines, each otherwise byte-exact.
79
+ expect(lines).toContain("// phantom reveal _WEBHOOK_SECRET");
80
+ expect(lines).toContain("// // (`phantom reveal _WEBHOOK_SECRET`) and compare in");
81
+ expect(count(stub, "phantom reveal _WEBHOOK_SECRET")).toBe(2);
82
+
83
+ // Single pass, every slot resolved: no `${...}` residue anywhere.
84
+ expect(stub).not.toContain("${");
85
+
86
+ // The sibling slots took their generic defaults in the same pass — the
87
+ // empty name reaches none of the known-provider switch cases.
88
+ expect(lines).toContain('// const signature = req.headers.get("x-webhook-signature");');
89
+ expect(lines).toContain("// // Verify per the provider's documented signature scheme.");
90
+ expect(verificationHintFor("")).toBe(verificationHintFor("zzq"));
91
+ });
92
+
93
+ it("reports the empty name in skipped byte-for-byte beside a wired known provider", async () => {
94
+ // Through `wirePhantomForRecipe` the empty name matches no catalog
95
+ // provider: `skipped` holds the empty string itself — pushed, not
96
+ // dropped — while the known provider listed beside it wires normally,
97
+ // unaffected by the empty neighbour.
98
+ const stubPath = join(dir, ".stack", "webhooks", "github.ts");
99
+ const result = await wirePhantomForRecipe(recipe("", "github"), { cwd: dir });
100
+
101
+ expect(result).toEqual({
102
+ present: [],
103
+ missing: ["github:GITHUB_TOKEN"],
104
+ webhooks: [stubPath],
105
+ skipped: [""],
106
+ });
107
+ expect(result.skipped).toHaveLength(1);
108
+ expect(result.skipped[0]).toBe("");
109
+
110
+ // Only github's documented stub lands — nothing for the empty name, and
111
+ // the stub on disk is the same render `webhookStubFor` returns.
112
+ expect(readdirSync(join(dir, ".stack", "webhooks"))).toEqual(["github.ts"]);
113
+ expect(readFileSync(stubPath, "utf-8")).toBe(webhookStubFor("github"));
114
+ expect(existsSync(join(dir, ".stack", "webhooks", ".ts"))).toBe(false);
115
+
116
+ // The whole call log is reads (`--version`, `list`); the vault file
117
+ // still parses to an empty object — an empty name provokes no write.
118
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
119
+ expect(harness.callsTo("add")).toEqual([]);
120
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
121
+ });
122
+ });
@@ -0,0 +1,145 @@
1
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { type Harness, setupFakePhantom } from "../__tests__/_harness.ts";
5
+ import { exec, isPhantomInstalled, listSecrets } from "../phantom.ts";
6
+ import { wirePhantomForRecipe } from "./phantom-wire.ts";
7
+ import type { Recipe } from "./recipe.ts";
8
+
9
+ // Each test spawns the fake `phantom` shim (bash + python3).
10
+ setDefaultTimeout(30_000);
11
+
12
+ const RECIPE: Recipe = {
13
+ id: "silent-failure",
14
+ query: "payments",
15
+ createdAt: "2026-04-18T00:00:00.000Z",
16
+ providers: [{ name: "stripe" }, { name: "neon" }],
17
+ };
18
+
19
+ /**
20
+ * Rewrite the harness `phantom`'s `list` arm to exit nonzero without a single
21
+ * stderr byte — a real crash shape (a killed wrapper, a denied helper, a
22
+ * build that prints nothing). `--version` still answers so the install probe
23
+ * passes and `list --json` is the subprocess that fails. The logging preamble
24
+ * is preserved so `callsTo` still sees every spawned command. `stdout`
25
+ * optionally carries output the failed call must not legitimise.
26
+ */
27
+ function failListSilently(h: Harness, stdout = ""): void {
28
+ const bin = join(h.dir, "phantom");
29
+ const shim = readFileSync(bin, "utf-8");
30
+ expect(shim).toContain(" list)\n");
31
+ writeFileSync(
32
+ bin,
33
+ shim.replace(" list)\n", ` list)\n printf '%s' '${stdout}'\n exit 9\n`),
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Rewrite only the `--version` arm to exit nonzero with empty stderr, so the
39
+ * install probe itself is the silently failing subprocess.
40
+ */
41
+ function failVersionSilently(h: Harness): void {
42
+ const bin = join(h.dir, "phantom");
43
+ const shim = readFileSync(bin, "utf-8");
44
+ expect(shim).toContain(" --version)\n");
45
+ writeFileSync(bin, shim.replace(" --version)\n", " --version)\n exit 9\n"));
46
+ }
47
+
48
+ describe("ai/phantom-wire when the subprocess exits nonzero with empty stderr", () => {
49
+ let harness: Harness;
50
+
51
+ beforeEach(() => {
52
+ // The vault file really holds STRIPE_SECRET_KEY: a successful inventory
53
+ // would report it `present`. The failed `list` must not.
54
+ harness = setupFakePhantom({ STRIPE_SECRET_KEY: "x" });
55
+ });
56
+ afterEach(() => {
57
+ harness.cleanup();
58
+ });
59
+
60
+ it("keeps a silently failing inventory distinct from a successful read and reports why", async () => {
61
+ failListSilently(harness);
62
+
63
+ // The returned exec failure is distinguishable from success on its own:
64
+ // nonzero code, an "exited" completion, and genuinely empty streams.
65
+ const result = await exec(["list", "--json"], { cwd: harness.dir, allowFailure: true });
66
+ expect(result).toMatchObject({ code: 9, stderr: "", stdout: "", completion: "exited" });
67
+ expect(result.code).not.toBe(0);
68
+
69
+ // The thrown diagnostic names the unavailable inventory outright — it
70
+ // never depended on stderr carrying a message.
71
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
72
+ code: "PHANTOM_METADATA_UNAVAILABLE",
73
+ message: "Phantom secret inventory is unavailable; stored names are unknown.",
74
+ });
75
+
76
+ // Wire keeps both providers in `skipped` — the failure stays
77
+ // distinguishable from a successful read, which would have put
78
+ // `stripe:STRIPE_SECRET_KEY` in `present` and the Neon secrets in
79
+ // `missing`. Neither success-shaped entry may appear.
80
+ const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
81
+ expect(await wirePhantomForRecipe(RECIPE, { cwd: harness.dir })).toEqual({
82
+ present: [],
83
+ missing: [],
84
+ webhooks: [stubPath],
85
+ skipped: ["stripe", "neon"],
86
+ });
87
+ expect(existsSync(stubPath)).toBe(true);
88
+ expect(readFileSync(stubPath, "utf-8")).toContain("stripe-signature");
89
+ expect(harness.callsTo("add")).toEqual([]);
90
+ });
91
+
92
+ it("never treats stdout from a silently failed list as a successful inventory", async () => {
93
+ // A plausible-looking payload on stdout does not upgrade a nonzero exit
94
+ // to success: the vault stays unread and every provider is skipped.
95
+ failListSilently(harness, '[{"name":"STRIPE_SECRET_KEY"}]');
96
+
97
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
98
+ code: "PHANTOM_METADATA_UNAVAILABLE",
99
+ });
100
+ const wire = await wirePhantomForRecipe(RECIPE, { cwd: harness.dir });
101
+ expect(wire.present).toEqual([]);
102
+ expect(wire.missing).toEqual([]);
103
+ expect(wire.skipped).toEqual(["stripe", "neon"]);
104
+ expect(wire.webhooks).toEqual([join(harness.dir, ".stack/webhooks/stripe.ts")]);
105
+ });
106
+
107
+ it("maps a silently crashing --version probe to not-installed, never to success", async () => {
108
+ failVersionSilently(harness);
109
+
110
+ // The probe reads the nonzero close as missing — distinguishable from an
111
+ // installed phantom — and the typed diagnostic carries the install hint.
112
+ expect(await isPhantomInstalled()).toBe(false);
113
+ await expect(listSecrets(harness.dir)).rejects.toMatchObject({
114
+ code: "PHANTOM_NOT_INSTALLED",
115
+ });
116
+
117
+ // Wire skips every provider, still writes the stub, and never reaches
118
+ // the inventory subprocess at all.
119
+ const stubPath = join(harness.dir, ".stack/webhooks/stripe.ts");
120
+ expect(await wirePhantomForRecipe(RECIPE, { cwd: harness.dir })).toEqual({
121
+ present: [],
122
+ missing: [],
123
+ webhooks: [stubPath],
124
+ skipped: ["stripe", "neon"],
125
+ });
126
+ expect(existsSync(stubPath)).toBe(true);
127
+ expect(harness.callsTo("--version")).toEqual([{ cmd: "--version", args: [] }]);
128
+ expect(harness.callsTo("list")).toEqual([]);
129
+ expect(harness.callsTo("add")).toEqual([]);
130
+ });
131
+
132
+ it("keeps the command and exit code in the rejection when stderr is empty", async () => {
133
+ failListSilently(harness);
134
+
135
+ // Callers that do not allowFailure get a rejection that still diagnoses:
136
+ // the argv and exit code are interpolated into the message, not the
137
+ // (empty) stderr text.
138
+ await expect(exec(["list", "--json"], { cwd: harness.dir })).rejects.toMatchObject({
139
+ code: "PHANTOM_EXEC_FAILED",
140
+ });
141
+ await expect(exec(["list", "--json"], { cwd: harness.dir })).rejects.toThrow(
142
+ /phantom list --json failed \(exit 9\)/,
143
+ );
144
+ });
145
+ });
@@ -0,0 +1,81 @@
1
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { 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 empty template (the empty name as replacement text)", () => {
14
+ let harness: Harness;
15
+ let dir: string;
16
+
17
+ beforeEach(() => {
18
+ harness = setupFakePhantom();
19
+ dir = mkdtempSync(join(tmpdir(), "wire-empty-template-"));
20
+ });
21
+ afterEach(() => {
22
+ harness.cleanup();
23
+ rmSync(dir, { recursive: true, force: true });
24
+ });
25
+
26
+ const recipe = (...names: string[]): Recipe => ({
27
+ id: "empty-template",
28
+ query: "q",
29
+ createdAt: "2026-04-18T00:00:00.000Z",
30
+ providers: names.map((name) => ({ name })),
31
+ });
32
+
33
+ it("substitutes the empty string at every expansion site", () => {
34
+ // The name is the empty template: every slot the name feeds resolves
35
+ // to the empty string. Substitution returns "" — the slot vanishes
36
+ // between the template's own fixed text instead of leaving residue.
37
+ const stub = webhookStubFor("");
38
+
39
+ // Case-preserving sites collapse to nothing: `forge a webhook.` keeps
40
+ // the double space where the name sat, proving the slot emitted ""
41
+ // rather than being skipped (a skipped slot would print `${provider}`).
42
+ expect(stub).toContain("webhook handler stub for .");
43
+ expect(stub).toContain("forge a webhook.");
44
+
45
+ // `capitalize("")` must return "": its `s === ""` early return is the
46
+ // only thing keeping `codePointAt(0)`'s undefined out of
47
+ // `String.fromCodePoint`, which would throw a RangeError on NaN.
48
+ expect(stub).toContain("handleWebhook(req: Request)");
49
+
50
+ // `"".toUpperCase()` is "" at both reveal sites — the secret-key slot
51
+ // ran through the default switch case and emitted the bare suffix.
52
+ expect(stub.split("phantom reveal _WEBHOOK_SECRET").length - 1).toBe(2);
53
+
54
+ // Single pass, every slot resolved: the render holds zero `${...}`
55
+ // spans and no doubled placeholder text.
56
+ expect(stub).not.toContain("${");
57
+
58
+ // The sibling slots took their generic defaults in the same pass — ""
59
+ // reaches none of the known-provider switch cases.
60
+ expect(stub).toContain('req.headers.get("x-webhook-signature")');
61
+ expect(stub).toContain("// // Verify per the provider's documented signature scheme.");
62
+ expect(verificationHintFor("")).toBe(verificationHintFor("zzq"));
63
+ });
64
+
65
+ it("reports the empty name in skipped byte-for-byte and writes nothing for it", async () => {
66
+ // "" matches no catalog provider and no webhook set: `skipped` is a
67
+ // single-element array holding the empty string itself — pushed, not
68
+ // dropped — and no stub lands on disk.
69
+ const result = await wirePhantomForRecipe(recipe(""), { cwd: dir });
70
+
71
+ expect(result).toEqual({ present: [], missing: [], webhooks: [], skipped: [""] });
72
+ expect(result.skipped).toHaveLength(1);
73
+ expect(readdirSync(dir)).toEqual([]);
74
+
75
+ // The whole call log is reads (`--version`, `list`); the vault file
76
+ // still parses to an empty object — an empty name provokes no write.
77
+ expect(harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list")).toBe(true);
78
+ expect(harness.callsTo("add")).toEqual([]);
79
+ expect(JSON.parse(readFileSync(harness.vaultPath, "utf-8"))).toEqual({});
80
+ });
81
+ });
@@ -0,0 +1,129 @@
1
+ import { afterEach, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } 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 { wirePhantomForRecipe } from "./phantom-wire.ts";
7
+ import type { Recipe } from "./recipe.ts";
8
+
9
+ // Each test spawns the fake `phantom` shim (bash + python3).
10
+ setDefaultTimeout(30_000);
11
+
12
+ const EMPTY_RESULT = { present: [], missing: [], webhooks: [], skipped: [] };
13
+
14
+ describe("ai/phantom-wire empty set of requested values", () => {
15
+ let harness: Harness;
16
+ let dir: string;
17
+
18
+ beforeEach(() => {
19
+ harness = setupFakePhantom();
20
+ dir = mkdtempSync(join(tmpdir(), "stack wire empty-values-"));
21
+ });
22
+ afterEach(() => {
23
+ harness.cleanup();
24
+ rmSync(dir, { recursive: true, force: true });
25
+ });
26
+
27
+ const recipe = (...names: string[]): Recipe => ({
28
+ id: "empty-values",
29
+ query: "q",
30
+ createdAt: "2026-04-18T00:00:00.000Z",
31
+ providers: names.map((name) => ({ name })),
32
+ });
33
+
34
+ const vaultText = () => readFileSync(harness.vaultPath, "utf-8");
35
+ // Every logged phantom call must be a read: the install probe or `list`.
36
+ // A placeholder write could only arrive through `add`/`init`/`remove`/`sync`.
37
+ const onlyReadCalls = () =>
38
+ harness.calls().every((c) => c.cmd === "--version" || c.cmd === "list");
39
+
40
+ it("writes no placeholder for a recipe with no providers", async () => {
41
+ const before = vaultText();
42
+ const result = await wirePhantomForRecipe(recipe(), { cwd: dir });
43
+
44
+ // Nothing was requested, so nothing can be present, missing, skipped or
45
+ // stubbed — the documented empty result.
46
+ expect(result).toEqual(EMPTY_RESULT);
47
+ expect(onlyReadCalls()).toBe(true);
48
+ expect(harness.callsTo("add")).toEqual([]);
49
+ // The vault file is byte-identical and no placeholder artifact lands on
50
+ // disk: no `.stack/` tree, no `.env`, nothing.
51
+ expect(vaultText()).toBe(before);
52
+ expect(JSON.parse(vaultText())).toEqual({});
53
+ expect(readdirSync(dir)).toEqual([]);
54
+ });
55
+
56
+ it("makes no external process calls for a recipe with no providers", async () => {
57
+ const result = await wirePhantomForRecipe(recipe(), { cwd: dir });
58
+
59
+ // Not even the `--version` install probe or `list --json` may run.
60
+ expect(result).toEqual(EMPTY_RESULT);
61
+ expect(harness.calls()).toEqual([]);
62
+ });
63
+
64
+ it("leaves a populated vault byte-identical when nothing is requested", async () => {
65
+ writeFileSync(
66
+ harness.vaultPath,
67
+ JSON.stringify({ STRIPE_SECRET_KEY: "stored-real-value", GITHUB_TOKEN: "gh-tok" }),
68
+ );
69
+ const before = vaultText();
70
+
71
+ const result = await wirePhantomForRecipe(recipe(), { cwd: dir });
72
+
73
+ // An empty request reports nothing — not even the stored names — and
74
+ // must not reconcile the vault: nothing moves to `present`, no stored
75
+ // name is dropped or rewritten.
76
+ expect(result).toEqual(EMPTY_RESULT);
77
+ expect(onlyReadCalls()).toBe(true);
78
+ expect(harness.callsTo("add")).toEqual([]);
79
+ expect(vaultText()).toBe(before);
80
+ expect(JSON.parse(vaultText())).toEqual({
81
+ STRIPE_SECRET_KEY: "stored-real-value",
82
+ GITHUB_TOKEN: "gh-tok",
83
+ });
84
+ expect(readdirSync(dir)).toEqual([]);
85
+ });
86
+
87
+ it("writes no placeholder when every named provider misses the catalog", async () => {
88
+ const before = vaultText();
89
+ const result = await wirePhantomForRecipe(recipe("nope", "also-nope"), { cwd: dir });
90
+
91
+ // Unknown names resolve no secret list, so the requested set stays
92
+ // empty: they are reported skipped, never missing, and no stub lands.
93
+ expect(result).toEqual({
94
+ present: [],
95
+ missing: [],
96
+ webhooks: [],
97
+ skipped: ["nope", "also-nope"],
98
+ });
99
+ expect(onlyReadCalls()).toBe(true);
100
+ expect(harness.callsTo("add")).toEqual([]);
101
+ expect(vaultText()).toBe(before);
102
+ expect(readdirSync(dir)).toEqual([]);
103
+ });
104
+
105
+ it("writes no placeholder when the vault cannot be read", async () => {
106
+ // Corrupt the vault file so `list --json` exits nonzero. The requested
107
+ // set is still empty and the failed read must not trigger a write.
108
+ writeFileSync(harness.vaultPath, "not json");
109
+
110
+ const result = await wirePhantomForRecipe(recipe(), { cwd: dir });
111
+
112
+ expect(result).toEqual(EMPTY_RESULT);
113
+ expect(onlyReadCalls()).toBe(true);
114
+ expect(harness.callsTo("add")).toEqual([]);
115
+ // The unreadable file is left alone — no repair write, no placeholder.
116
+ expect(vaultText()).toBe("not json");
117
+ expect(readdirSync(dir)).toEqual([]);
118
+ });
119
+
120
+ it("spawns nothing at all when the empty request opts out with noWire", async () => {
121
+ const before = vaultText();
122
+ const result = await wirePhantomForRecipe(recipe(), { cwd: dir, noWire: true });
123
+
124
+ expect(result).toEqual(EMPTY_RESULT);
125
+ expect(harness.calls()).toEqual([]);
126
+ expect(vaultText()).toBe(before);
127
+ expect(existsSync(join(dir, ".stack"))).toBe(false);
128
+ });
129
+ });