create-ailk 0.10.0 → 0.11.1

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 (103) hide show
  1. package/README.md +7 -2
  2. package/dist/cli.js +5 -1
  3. package/dist/content-payloads.d.ts +37 -0
  4. package/dist/content-payloads.js +64 -0
  5. package/dist/lib/fetch-module.d.ts +3 -3
  6. package/dist/lib/fetch-module.js +11 -5
  7. package/dist/lib/fetch-template.d.ts +1 -1
  8. package/dist/lib/fetch-template.js +7 -4
  9. package/dist/lib/scaffold-render-sweep.d.ts +16 -0
  10. package/dist/lib/scaffold-render-sweep.js +52 -3
  11. package/dist/module-architecture.d.ts +9 -0
  12. package/dist/module-architecture.js +43 -12
  13. package/dist/parse-args.d.ts +2 -0
  14. package/dist/parse-args.js +7 -0
  15. package/dist/surfaces.js +2 -3
  16. package/package.json +3 -3
  17. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +4 -3
  18. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +8 -8
  19. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +8 -8
  20. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +8 -8
  21. package/site-templates/shapes/organization-service/content/en/Service/first-engagement.mdx +8 -8
  22. package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +8 -8
  23. package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +8 -8
  24. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +8 -8
  25. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +8 -8
  26. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +8 -8
  27. package/templates/.claude/settings.json +12 -0
  28. package/templates/.env.example +1 -5
  29. package/templates/CLAUDE.md +38 -22
  30. package/templates/README.md +12 -12
  31. package/templates/apps/api/jest.config.cjs +1 -0
  32. package/templates/apps/api/package.json +2 -1
  33. package/templates/apps/mcp/CLAUDE.md +23 -10
  34. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +160 -17
  35. package/templates/apps/mcp/__tests__/index.test.ts +118 -1
  36. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +425 -5
  37. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +67 -3
  38. package/templates/apps/mcp/__tests__/server.test.ts +162 -1
  39. package/templates/apps/mcp/package.json +1 -1
  40. package/templates/apps/mcp/src/index.ts +35 -3
  41. package/templates/apps/mcp/src/lib/commerce-claim.ts +193 -24
  42. package/templates/apps/mcp/src/server.ts +77 -5
  43. package/templates/apps/mcp/src/tools/purchase.ts +1 -1
  44. package/templates/apps/web/CLAUDE.md +1 -1
  45. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +15 -0
  46. package/templates/apps/web/app/.well-known/mcp.json/route.ts +10 -3
  47. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +0 -10
  48. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +0 -10
  49. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +1 -14
  50. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +0 -10
  51. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +0 -10
  52. package/templates/apps/web/app/[locale]/for/[slug]/page.tsx +0 -10
  53. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +1 -14
  54. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +0 -10
  55. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +0 -10
  56. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +0 -10
  57. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +0 -10
  58. package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +1 -15
  59. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +0 -10
  60. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +0 -10
  61. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +0 -10
  62. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +1 -14
  63. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +4 -16
  64. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +3 -15
  65. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +0 -10
  66. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +1 -14
  67. package/templates/apps/web/src/lib/commerce-claim.ts +15 -6
  68. package/templates/content/_site.mdx +23 -4
  69. package/templates/database/CHANGELOG.md +7 -0
  70. package/templates/database/package.json +1 -1
  71. package/versions.json +34 -34
  72. package/templates/.claude/skills/README.md +0 -37
  73. package/templates/.claude/skills/add-auth/SKILL.md +0 -313
  74. package/templates/.claude/skills/add-auth/references/env-vars.md +0 -106
  75. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +0 -180
  76. package/templates/.claude/skills/add-page/SKILL.md +0 -260
  77. package/templates/.claude/skills/add-page/references/page-types.md +0 -123
  78. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +0 -87
  79. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +0 -99
  80. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +0 -18
  81. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +0 -43
  82. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +0 -52
  83. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +0 -38
  84. package/templates/.claude/skills/check-site/SKILL.md +0 -245
  85. package/templates/.claude/skills/check-site/references/common-errors.md +0 -116
  86. package/templates/.claude/skills/check-site/references/findings-glossary.md +0 -140
  87. package/templates/.claude/skills/check-site/references/severity-levels.md +0 -114
  88. package/templates/.claude/skills/fill-page/SKILL.md +0 -199
  89. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +0 -80
  90. package/templates/.claude/skills/fill-page/references/section-sources.md +0 -41
  91. package/templates/.claude/skills/setup-database/SKILL.md +0 -156
  92. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +0 -154
  93. package/templates/.claude/skills/setup-deploy/SKILL.md +0 -312
  94. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +0 -204
  95. package/templates/.claude/skills/setup-deploy/references/vercel-json-template.md +0 -98
  96. package/templates/.claude/skills/update-page/SKILL.md +0 -117
  97. package/templates/.claude/skills/update-page/scripts/update-page.ts +0 -640
  98. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +0 -31
  99. package/templates/.claude/skills/upgrade-plan/SKILL.md +0 -121
  100. package/templates/.claude/skills/upgrade-plan/references/options.md +0 -24
  101. package/templates/.claude/skills/upgrade-plan/scripts/upgrade-plan.ts +0 -527
  102. package/templates/apps/mcp/__tests__/lib/require-paid-tier.test.ts +0 -75
  103. package/templates/apps/mcp/src/lib/require-paid-tier.ts +0 -44
@@ -13,6 +13,10 @@
13
13
  * `start_purchase` visible, so comparing a claimed server against an
14
14
  * unclaimed catalog (or vice versa) would compare unlike surfaces and
15
15
  * report drift that isn't there — or, worse, miss drift that is.
16
+ * - …and under every licence-feature state × gate mode (#7136): 2 commerce
17
+ * states × {warn, refuse} × 4 held-feature sets = 16. In refuse mode a
18
+ * feature's tools are in both surfaces iff the feature is held; in warn
19
+ * mode all five are in both.
16
20
  *
17
21
  * What this does NOT assert:
18
22
  * - inputSchema equality — the server registers Zod schemas; the catalog
@@ -21,14 +25,35 @@
21
25
  */
22
26
 
23
27
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
- import { getMcpToolCatalog } from "@working-theory/metadata";
28
+ import {
29
+ getMcpToolCatalog,
30
+ type McpCatalogOptions,
31
+ type McpToolCatalogEntry,
32
+ } from "@working-theory/metadata";
25
33
 
26
34
  import {
35
+ MCP_CLAIM_REFRESH_MS,
27
36
  __setMcpCommerceClaimForTest,
37
+ __setMcpFeatureGateForTest,
38
+ __setMcpHeldFeaturesForTest,
39
+ initMcpCommerceClaim,
28
40
  resetMcpCommerceClaim,
41
+ startMcpClaimRefresh,
29
42
  type McpCommerceClaim,
30
43
  } from "../src/lib/commerce-claim.js";
31
- import { createMcpServer } from "../src/server.js";
44
+ import { createMcpServer, listRegisteredTools } from "../src/server.js";
45
+
46
+ /** The injected licence reader — same seam `commerce-claim.test.ts` types. */
47
+ type CheckLicenseSeam = NonNullable<
48
+ NonNullable<Parameters<typeof initMcpCommerceClaim>[0]>["checkLicense"]
49
+ >;
50
+
51
+ type LicenseShape = Awaited<ReturnType<CheckLicenseSeam>>;
52
+
53
+ /** A licence reader that resolves to the given (partial) result. */
54
+ function resolving(result: Partial<LicenseShape>): CheckLicenseSeam {
55
+ return () => Promise.resolve(result as LicenseShape);
56
+ }
32
57
 
33
58
  interface RegisteredTool {
34
59
  description?: string;
@@ -47,29 +72,70 @@ const PAID_MODULES = "scheduling,content-write,signal-collectors";
47
72
  * @modelcontextprotocol/sdk ^1.29.0 — if the SDK renames the field, change it
48
73
  * here once rather than at each call site.
49
74
  */
50
- function registeredToolsOf(
51
- server: McpServer,
52
- ): Record<string, RegisteredTool> {
75
+ function registeredToolsOf(server: McpServer): Record<string, RegisteredTool> {
53
76
  return (
54
77
  // eslint-disable-next-line no-restricted-syntax -- #506/#507: the SDK exposes no public accessor; the double assertion is confined to this one helper
55
- server as unknown as { _registeredTools: Record<string, RegisteredTool> }
56
- )._registeredTools;
78
+ (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
79
+ ._registeredTools
80
+ );
57
81
  }
58
82
 
83
+ type GateMode = NonNullable<McpCatalogOptions["featureGate"]>;
84
+
85
+ type McpFeature = NonNullable<McpToolCatalogEntry["feature"]>;
86
+
87
+ /** The five licence-feature tools, by the feature that entitles them. */
88
+ const FEATURE_TOOLS: Record<McpFeature, readonly string[]> = {
89
+ scheduling: ["list_availability", "get_event_meta", "create_booking"],
90
+ "flows-deliverables": ["get_flow", "submit_flow_step"],
91
+ };
92
+
93
+ const HELD_SETS: readonly (readonly McpFeature[])[] = [
94
+ [],
95
+ ["scheduling"],
96
+ ["flows-deliverables"],
97
+ ["scheduling", "flows-deliverables"],
98
+ ];
99
+
100
+ /** Every claim state: commerce × gate mode × held features (16). */
101
+ const CLAIM_STATES = (["unclaimed", "claimed"] as const).flatMap((claim) =>
102
+ (["warn", "refuse"] as const).flatMap((mode) =>
103
+ HELD_SETS.map(
104
+ (held) =>
105
+ [claim, mode, held] as [
106
+ McpCommerceClaim,
107
+ GateMode,
108
+ readonly McpFeature[],
109
+ ],
110
+ ),
111
+ ),
112
+ );
113
+
59
114
  /** The catalog options a given claim state implies — the same mapping
60
115
  * `server.ts` applies internally, so both surfaces are read like for like. */
61
- function catalogOptionsFor(claim: McpCommerceClaim) {
62
- return { addons: { commerce: claim === "claimed" } };
116
+ function catalogOptionsFor(
117
+ claim: McpCommerceClaim,
118
+ mode: GateMode,
119
+ held: readonly McpFeature[],
120
+ ): McpCatalogOptions {
121
+ return {
122
+ addons: { commerce: claim === "claimed" },
123
+ features: held,
124
+ featureGate: mode,
125
+ };
63
126
  }
64
127
 
65
- describe.each<McpCommerceClaim>(["unclaimed", "claimed"])(
66
- "MCP catalog ↔ server drift — commerce claim %s",
67
- (claim) => {
128
+ describe.each(CLAIM_STATES)(
129
+ "MCP catalog ↔ server drift — commerce %s, mode %s, held %j",
130
+ (claim, mode, held) => {
68
131
  const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
132
+ const options = catalogOptionsFor(claim, mode, held);
69
133
 
70
134
  beforeEach(() => {
71
135
  process.env.AILK_ENABLED_MODULES = PAID_MODULES;
72
136
  __setMcpCommerceClaimForTest(claim);
137
+ __setMcpFeatureGateForTest(mode);
138
+ __setMcpHeldFeaturesForTest(held);
73
139
  });
74
140
 
75
141
  afterEach(() => {
@@ -87,7 +153,7 @@ describe.each<McpCommerceClaim>(["unclaimed", "claimed"])(
87
153
  it("registered tool names equal catalog tool names", () => {
88
154
  const registered = registeredToolsOf(createMcpServer());
89
155
  const registeredNames = Object.keys(registered).sort();
90
- const catalogNames = getMcpToolCatalog(catalogOptionsFor(claim))
156
+ const catalogNames = getMcpToolCatalog(options)
91
157
  .map((t) => t.name)
92
158
  .sort();
93
159
  expect(registeredNames).toEqual(catalogNames);
@@ -95,7 +161,7 @@ describe.each<McpCommerceClaim>(["unclaimed", "claimed"])(
95
161
 
96
162
  it("registered tool descriptions equal catalog descriptions", () => {
97
163
  const registered = registeredToolsOf(createMcpServer());
98
- for (const entry of getMcpToolCatalog(catalogOptionsFor(claim))) {
164
+ for (const entry of getMcpToolCatalog(options)) {
99
165
  expect(registered[entry.name]).toBeDefined();
100
166
  expect(registered[entry.name]!.description).toBe(entry.description);
101
167
  }
@@ -107,10 +173,87 @@ describe.each<McpCommerceClaim>(["unclaimed", "claimed"])(
107
173
 
108
174
  expect("start_purchase" in registered).toBe(expected);
109
175
  expect(
110
- getMcpToolCatalog(catalogOptionsFor(claim)).some(
111
- (t) => t.name === "start_purchase",
112
- ),
176
+ getMcpToolCatalog(options).some((t) => t.name === "start_purchase"),
113
177
  ).toBe(expected);
114
178
  });
179
+
180
+ it("each feature's tools appear in both surfaces exactly when the gate lets them", () => {
181
+ const registered = registeredToolsOf(createMcpServer());
182
+ const catalogNames = getMcpToolCatalog(options).map((t) => t.name);
183
+
184
+ for (const [feature, tools] of Object.entries(FEATURE_TOOLS)) {
185
+ const expected =
186
+ mode === "warn" || held.includes(feature as McpFeature);
187
+ for (const name of tools) {
188
+ expect(name in registered).toBe(expected);
189
+ expect(catalogNames.includes(name)).toBe(expected);
190
+ }
191
+ }
192
+ });
115
193
  },
116
194
  );
195
+
196
+ describe("MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1)", () => {
197
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
198
+
199
+ beforeEach(() => {
200
+ process.env.AILK_ENABLED_MODULES = PAID_MODULES;
201
+ jest.useFakeTimers();
202
+ });
203
+
204
+ afterEach(() => {
205
+ resetMcpCommerceClaim();
206
+ jest.useRealTimers();
207
+ if (priorEnabledModules === undefined) {
208
+ delete process.env.AILK_ENABLED_MODULES;
209
+ } else {
210
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
211
+ }
212
+ });
213
+
214
+ it("a new server and listRegisteredTools() match the catalog once the re-read resolves", async () => {
215
+ __setMcpFeatureGateForTest("refuse");
216
+ await initMcpCommerceClaim({
217
+ checkLicense: resolving({
218
+ tier: "pro",
219
+ features: ["scheduling", "flows-deliverables", "commerce"],
220
+ }),
221
+ });
222
+
223
+ const bootOptions: McpCatalogOptions = {
224
+ addons: { commerce: true },
225
+ features: ["scheduling", "flows-deliverables"],
226
+ featureGate: "refuse",
227
+ };
228
+ const bootNames = Object.keys(registeredToolsOf(createMcpServer())).sort();
229
+ expect(bootNames).toEqual(
230
+ getMcpToolCatalog(bootOptions)
231
+ .map((t) => t.name)
232
+ .sort(),
233
+ );
234
+ expect(bootNames).toHaveLength(20);
235
+
236
+ startMcpClaimRefresh({ checkLicense: resolving({ features: [] }) });
237
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
238
+
239
+ const refreshedOptions: McpCatalogOptions = {
240
+ addons: { commerce: false },
241
+ features: [],
242
+ featureGate: "refuse",
243
+ };
244
+ const catalogNames = getMcpToolCatalog(refreshedOptions)
245
+ .map((t) => t.name)
246
+ .sort();
247
+ expect(catalogNames).toHaveLength(14);
248
+
249
+ const serverNames = Object.keys(
250
+ registeredToolsOf(createMcpServer()),
251
+ ).sort();
252
+ expect(serverNames).toEqual(catalogNames);
253
+
254
+ const listedNames = listRegisteredTools()
255
+ .map((t) => t.name)
256
+ .sort();
257
+ expect(listedNames).toEqual(catalogNames);
258
+ });
259
+ });
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Structural ordering guard for apps/mcp's bootstrap (#5876 spec D4, AC-3).
2
+ * Structural ordering guard for apps/mcp's bootstrap (#5876 spec D4, AC-3;
3
+ * #7156 AC-1, AC-2).
3
4
  *
4
5
  * `apps/mcp/src/index.ts` has real side effects at import time (starts a
5
6
  * transport, opens a stdio/HTTP listener) — it cannot safely be imported by
@@ -8,6 +9,9 @@
8
9
  */
9
10
  import { readFileSync } from "node:fs";
10
11
  import { resolve } from "node:path";
12
+ import vm from "node:vm";
13
+
14
+ import * as ts from "typescript";
11
15
 
12
16
  describe("apps/mcp/src/index.ts — bootstrap ordering", () => {
13
17
  const src = readFileSync(resolve(__dirname, "../src/index.ts"), "utf-8");
@@ -37,3 +41,116 @@ describe("apps/mcp/src/index.ts — bootstrap ordering", () => {
37
41
  expect(initIdx).toBeLessThan(catchIdx);
38
42
  });
39
43
  });
44
+
45
+ describe("apps/mcp/src/index.ts — the claim refresh loop starts in production (#7156 AC-1)", () => {
46
+ const src = readFileSync(resolve(__dirname, "../src/index.ts"), "utf-8");
47
+
48
+ it("starts after the boot read and before the transport branch", () => {
49
+ const initIdx = src.indexOf("await initMcpCommerceClaim()");
50
+ const callMatches = [...src.matchAll(/^startMcpClaimRefresh\(\);$/gm)];
51
+ const transportBranchIdx = src.indexOf('if (transport === "http")');
52
+
53
+ expect(initIdx).toBeGreaterThan(-1);
54
+ expect(callMatches).toHaveLength(1);
55
+ expect(transportBranchIdx).toBeGreaterThan(-1);
56
+
57
+ const callIdx = callMatches[0]!.index!;
58
+ expect(initIdx).toBeLessThan(callIdx);
59
+ expect(callIdx).toBeLessThan(transportBranchIdx);
60
+ });
61
+ });
62
+
63
+ describe("apps/mcp/src/index.ts — the boot warning cannot stop the server (#7156 AC-2, D2)", () => {
64
+ const src = readFileSync(resolve(__dirname, "../src/index.ts"), "utf-8");
65
+
66
+ /**
67
+ * The exact behavioural slice AC-2 specifies: from the last `try {` before
68
+ * the `reportUnlicensedProTools();` call statement, up to `const transport
69
+ * =`. Transpiled and run in a fresh vm context so a real throw from the
70
+ * stubbed function proves the guard, not a mock of the guard itself.
71
+ */
72
+ function guardedSlice(): string {
73
+ const callMatches = [
74
+ ...src.matchAll(/^\s*reportUnlicensedProTools\(\);$/gm),
75
+ ];
76
+ expect(callMatches).toHaveLength(1);
77
+ const callIdx = callMatches[0]!.index!;
78
+ const tryIdx = src.lastIndexOf("try {", callIdx);
79
+ const transportIdx = src.indexOf("const transport =");
80
+
81
+ expect(tryIdx).toBeGreaterThan(-1);
82
+ expect(transportIdx).toBeGreaterThan(callIdx);
83
+
84
+ const slice = src.slice(tryIdx, transportIdx);
85
+ // The extracted try block must be the one that immediately wraps the
86
+ // call, not an earlier try{}/await pair (code review, #7156): the two
87
+ // other try blocks in this file each await a boot call, so a regressed
88
+ // boundary would carry "await" into the slice. Asserting that here
89
+ // turns a boundary regression into a clear failure instead of an opaque
90
+ // vm ReferenceError from the un-injected awaited function.
91
+ expect(slice).not.toContain("await");
92
+
93
+ return slice;
94
+ }
95
+
96
+ function runGuardedSlice(
97
+ reportUnlicensedProTools: () => unknown,
98
+ errorSpy: jest.Mock,
99
+ ): void {
100
+ const slice = guardedSlice();
101
+ const { outputText } = ts.transpileModule(slice, {
102
+ compilerOptions: {
103
+ module: ts.ModuleKind.CommonJS,
104
+ target: ts.ScriptTarget.ES2020,
105
+ },
106
+ });
107
+ vm.runInNewContext(outputText, {
108
+ reportUnlicensedProTools,
109
+ console: { error: errorSpy },
110
+ });
111
+ }
112
+
113
+ it("a throw is logged to stderr and does not escape", () => {
114
+ const errorSpy = jest.fn();
115
+
116
+ expect(() =>
117
+ runGuardedSlice(() => {
118
+ throw new Error("boom");
119
+ }, errorSpy),
120
+ ).not.toThrow();
121
+
122
+ expect(errorSpy).toHaveBeenCalledTimes(1);
123
+ const call = errorSpy.mock.calls[0]!;
124
+ expect(String(call[0])).toContain("reportUnlicensedProTools");
125
+ const lastArg = call[call.length - 1];
126
+ expect(lastArg).toBeInstanceOf(Error);
127
+ expect((lastArg as Error).message).toBe("boom");
128
+ });
129
+
130
+ it("a successful call is not logged", () => {
131
+ const errorSpy = jest.fn();
132
+ const report = jest.fn(() => []);
133
+
134
+ runGuardedSlice(report, errorSpy);
135
+
136
+ expect(report).toHaveBeenCalledTimes(1);
137
+ expect(errorSpy).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("the call statement occurs exactly once, before the transport branch", () => {
141
+ const callMatches = [
142
+ ...src.matchAll(/^\s*reportUnlicensedProTools\(\);$/gm),
143
+ ];
144
+ const transportBranchIdx = src.indexOf('if (transport === "http")');
145
+
146
+ expect(callMatches).toHaveLength(1);
147
+ expect(callMatches[0]!.index!).toBeLessThan(transportBranchIdx);
148
+ });
149
+
150
+ it("logs to stderr only — the guarded slice never writes stdout", () => {
151
+ const slice = guardedSlice();
152
+
153
+ expect(slice).toContain("console.error");
154
+ expect(slice).not.toContain("console.log");
155
+ });
156
+ });