pi-lean-dimension 0.5.0 → 0.6.0

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 (104) hide show
  1. package/README.md +5 -3
  2. package/node_modules/pi-lean-host/AGENTS.md +13 -6
  3. package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
  4. package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
  5. package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
  6. package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
  7. package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
  8. package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
  9. package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
  10. package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
  11. package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
  12. package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
  13. package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
  14. package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
  15. package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
  16. package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
  17. package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
  18. package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
  19. package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
  20. package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
  21. package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
  22. package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
  23. package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
  24. package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
  25. package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
  26. package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
  27. package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
  28. package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
  29. package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
  30. package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
  31. package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
  32. package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
  33. package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
  34. package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
  35. package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
  36. package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
  37. package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
  38. package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
  39. package/node_modules/pi-lean-host/core/auth.ts +2 -1
  40. package/node_modules/pi-lean-host/core/helpers.ts +29 -35
  41. package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
  42. package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
  43. package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
  44. package/node_modules/pi-lean-host/core/transport.ts +1 -1
  45. package/node_modules/pi-lean-host/package.json +1 -1
  46. package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
  47. package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
  48. package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
  49. package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
  50. package/node_modules/pi-lean-host/tools/utils.ts +69 -0
  51. package/node_modules/pi-lean-portal/AGENTS.md +4 -3
  52. package/node_modules/pi-lean-portal/README.md +9 -9
  53. package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
  54. package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
  55. package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
  56. package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
  57. package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
  58. package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
  59. package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
  60. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
  61. package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
  62. package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
  63. package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
  64. package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
  65. package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
  66. package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
  67. package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
  68. package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
  69. package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
  70. package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
  71. package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
  72. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
  73. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
  74. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
  75. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
  76. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
  77. package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
  78. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
  80. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
  81. package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
  82. package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
  83. package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
  84. package/node_modules/pi-lean-portal/browser-install.ts +454 -0
  85. package/node_modules/pi-lean-portal/browser-status.ts +21 -0
  86. package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
  87. package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
  88. package/node_modules/pi-lean-portal/core/router.ts +7 -32
  89. package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
  90. package/node_modules/pi-lean-portal/index.ts +1 -18
  91. package/node_modules/pi-lean-portal/package.json +2 -1
  92. package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
  93. package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
  94. package/node_modules/pi-lean-search/AGENTS.md +5 -3
  95. package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
  96. package/node_modules/pi-lean-search/index.ts +0 -3
  97. package/node_modules/pi-lean-search/package.json +1 -1
  98. package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
  99. package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
  100. package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
  101. package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
  102. package/node_modules/yaml/dist/nodes/Alias.js +25 -23
  103. package/node_modules/yaml/package.json +1 -1
  104. package/package.json +4 -4
@@ -0,0 +1,484 @@
1
+ /**
2
+ * Guide-catalog structural tests: projectToGuide() projection, slug()
3
+ * sanitization, the directory loader + catalog formatter, and the
4
+ * stampFrontmatterField save-stamp editor.
5
+ *
6
+ * Split out of parse-api-guide.test.ts — these test
7
+ * core/guide-catalog.ts + core/path-template.ts concerns, not the parser
8
+ * schema. BOE_RECIPE/expectOk are duplicated (not imported) so both files
9
+ * stay standalone test modules.
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
13
+ import {
14
+ mkdtempSync,
15
+ mkdirSync,
16
+ writeFileSync,
17
+ rmSync,
18
+ renameSync,
19
+ } from "node:fs";
20
+ import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import {
23
+ parseApiGuide,
24
+ stampFrontmatterField,
25
+ } from "../core/parse-api-guide.js";
26
+ import {
27
+ projectToGuide,
28
+ loadApiGuidesFromDir,
29
+ formatApiGuideCatalog,
30
+ } from "../core/guide-catalog.js";
31
+ import { slug } from "../core/path-template.js";
32
+ import { type ApiGuide } from "../core/api-guide-types.js";
33
+
34
+ const BOE_RECIPE = `---
35
+ schemaVersion: 1
36
+ kind: api
37
+ domains: [boe.es, www.boe.es]
38
+ icon: ⚖️
39
+ shortName: BOE
40
+ updated: 2026-07-17
41
+ apiHost: https://apidatos.boe.es/v1
42
+ verified: 2026-07-17
43
+ gatherAllMax: 500
44
+
45
+ auth:
46
+ kind: none
47
+
48
+ pagination:
49
+ style: offset-limit
50
+ pageParam: page
51
+ pageSizeParam: limit
52
+ pageSize: 50
53
+ itemsPath: data
54
+
55
+ responseShape:
56
+ format: json
57
+ charset: utf-8
58
+
59
+ operations:
60
+ - name: searchDiary
61
+ via: restGet
62
+ path: /diario/{date}
63
+ accept: json
64
+ params:
65
+ limit:
66
+ default: 50
67
+ helper: true
68
+ parse:
69
+ format: xml
70
+ charset: iso-8859-1
71
+
72
+ - name: listConsolidada
73
+ via: paginate
74
+ path: /legislacion-consolidada
75
+ accept: json
76
+ pagination:
77
+ style: cursor
78
+ cursorParam: cursor
79
+ cursorPath: pagination.nextCursor
80
+ itemsPath: results
81
+ gatherAllMax: 1000
82
+ ---
83
+ # BOE Legislación Consolidada — structured API access
84
+
85
+ Use \`api-fetch\` with \`operation\` \`searchDiary\` to pull a day's dispatch.
86
+ `;
87
+
88
+ function expectOk(
89
+ raw: string,
90
+ opts?: Parameters<typeof parseApiGuide>[1],
91
+ ): ApiGuide {
92
+ const res = parseApiGuide(raw, opts);
93
+ if (!res.ok) {
94
+ throw new Error(
95
+ `expected ok, got error: ${res.error.field} — ${res.error.expected} (found: ${res.error.found})`,
96
+ );
97
+ }
98
+ return res.guide;
99
+ }
100
+
101
+ describe("projectToGuide", () => {
102
+ it("strips recipe fields and retains kind: api", () => {
103
+ const guide = expectOk(BOE_RECIPE, { filename: "boe.es" });
104
+ const proj = projectToGuide(guide);
105
+
106
+ const keys = Object.keys(proj);
107
+ const RECIPE_KEYS = [
108
+ "apiHost",
109
+ "operations",
110
+ "pagination",
111
+ "auth",
112
+ "helper",
113
+ "verified",
114
+ "gatherAllMax",
115
+ "responseShape",
116
+ ];
117
+ for (const k of RECIPE_KEYS) {
118
+ expect(keys).not.toContain(k);
119
+ }
120
+
121
+ expect(proj.kind).toBe("api");
122
+ expect(proj.domains).toEqual(["boe.es", "www.boe.es"]);
123
+ expect(proj.icon).toBe("⚖️");
124
+ expect(proj.shortName).toBe("BOE");
125
+ expect(proj.updated).toBe("2026-07-17");
126
+ expect(proj.content).toContain("BOE Legislación Consolidada");
127
+ expect(proj.category).toBe("site");
128
+ });
129
+
130
+ it("projection carries no helper reference", () => {
131
+ const guide = expectOk(BOE_RECIPE, { filename: "boe.es" });
132
+ const proj = projectToGuide(guide);
133
+ // Helper is a recipe (op-level) field; the Guide projection has no such key.
134
+ expect("helper" in proj).toBe(false);
135
+ expect("operations" in proj).toBe(false);
136
+ });
137
+ });
138
+
139
+ // ═══════════════════════════════════════════════════════════════════
140
+ // slug() — the shortName → identity-folder sanitizer
141
+ // ═══════════════════════════════════════════════════════════════════
142
+
143
+ describe("slug()", () => {
144
+ it("lowercases and replaces non-[a-z0-9-] runs with a single '-'", () => {
145
+ expect(slug("BOE")).toBe("boe");
146
+ expect(slug("My Provider Pro!")).toBe("my-provider-pro");
147
+ expect(slug("Example Dev Inc")).toBe("example-dev-inc");
148
+ expect(slug("a/b")).toBe("a-b");
149
+ });
150
+
151
+ it("transliterates Latin diacritics instead of dropping them", () => {
152
+ expect(slug("Café")).toBe("cafe");
153
+ expect(slug("Überwald")).toBe("uberwald");
154
+ expect(slug("Bjørk")).toBe("bjork");
155
+ expect(slug("Münchhausen")).toBe("munchhausen");
156
+ });
157
+
158
+ it("collapses repeated '-' and strips leading/trailing '-'", () => {
159
+ expect(slug("a--b")).toBe("a-b");
160
+ expect(slug("-foo-")).toBe("foo");
161
+ });
162
+
163
+ it("slug-collision pair: api_dev_full and api-dev-full both slug to api-dev-full", () => {
164
+ expect(slug("api_dev_full")).toBe("api-dev-full");
165
+ expect(slug("api-dev-full")).toBe("api-dev-full");
166
+ });
167
+
168
+ it("throws on empty or all-symbol shortName (slug flattens to empty)", () => {
169
+ expect(() => slug("")).toThrow(/shortName/);
170
+ expect(() => slug("!!!")).toThrow(/shortName/);
171
+ expect(() => slug("..")).toThrow(/shortName/);
172
+ });
173
+ });
174
+
175
+ // ═══════════════════════════════════════════════════════════════════
176
+ // Loader + catalog — one malformed guide doesn't block the store
177
+ // ═══════════════════════════════════════════════════════════════════
178
+
179
+ describe("loadApiGuidesFromDir + formatApiGuideCatalog", () => {
180
+ beforeEach(() => {
181
+ vi.spyOn(console, "warn").mockImplementation(() => {});
182
+ });
183
+ afterEach(() => {
184
+ vi.restoreAllMocks();
185
+ });
186
+
187
+ it("lists a healthy and a malformed guide together", () => {
188
+ const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
189
+ try {
190
+ // Healthy guide in subdirectory
191
+ const boeDir = join(dir, "boe");
192
+ mkdirSync(boeDir, { recursive: true });
193
+ writeFileSync(join(boeDir, "guide.md"), BOE_RECIPE);
194
+
195
+ // Malformed guide in subdirectory
196
+ const brokenDir = join(dir, "broken");
197
+ mkdirSync(brokenDir, { recursive: true });
198
+ writeFileSync(
199
+ join(brokenDir, "guide.md"),
200
+ `---
201
+ schemaVersion: 1
202
+ domains: [broken.com]
203
+ apiHost: https://api.broken.com
204
+ operations:
205
+ - name: get
206
+ via: restPost
207
+ path: /things
208
+ ---
209
+ body
210
+ `,
211
+ );
212
+
213
+ const loaded = loadApiGuidesFromDir(dir);
214
+ expect(Object.keys(loaded.guides)).toEqual(["boe"]);
215
+ expect(loaded.malformed).toHaveLength(1);
216
+ expect(loaded.malformed[0]!.filename).toBe("broken");
217
+ expect(loaded.malformed[0]!.error.field).toBe("operations[0].via");
218
+
219
+ const catalog = formatApiGuideCatalog(loaded);
220
+ expect(catalog).toContain("BOE");
221
+ expect(catalog).toContain("⚠ malformed");
222
+ expect(catalog).toContain("broken");
223
+ expect(catalog).toContain("operations[0].via");
224
+ } finally {
225
+ rmSync(dir, { recursive: true, force: true });
226
+ }
227
+ });
228
+
229
+ it("collapses the catalog by organization (org line + orgless fallback)", () => {
230
+ const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
231
+ try {
232
+ const orgRecipe = (d: string, shortName: string, domains: string) => `---
233
+ schemaVersion: 1
234
+ kind: api
235
+ domains: [${domains}]
236
+ organization: archive.org
237
+ description: ${shortName} surface.
238
+ icon: 🏛️
239
+ shortName: ${shortName}
240
+ updated: 2026-07-17
241
+ apiHost: https://${d}
242
+ verified: 2026-07-17
243
+ gatherAllMax: 500
244
+ auth:
245
+ kind: none
246
+ responseShape:
247
+ format: json
248
+ charset: utf-8
249
+ operations:
250
+ - name: get
251
+ via: restGet
252
+ path: /x
253
+ accept: json
254
+ ---
255
+ org guide.
256
+ `;
257
+ for (const [domain, folder, shortName] of [
258
+ ["archive.org", "archive", "Archive"],
259
+ ["web.archive.org", "wayback", "Wayback"],
260
+ ] as const) {
261
+ mkdirSync(join(dir, folder), { recursive: true });
262
+ writeFileSync(
263
+ join(dir, folder, "guide.md"),
264
+ orgRecipe(domain, shortName, domain),
265
+ );
266
+ }
267
+ // Orgless guide keeps the per-guide line (fallback).
268
+ mkdirSync(join(dir, "boe"), { recursive: true });
269
+ writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
270
+
271
+ const loaded = loadApiGuidesFromDir(dir);
272
+ const catalog = formatApiGuideCatalog(loaded);
273
+ // One org-collapsed line for archive.org with guide count + domain set.
274
+ expect(catalog).toContain(
275
+ "🏛️ archive.org — 2 guides (archive.org, web.archive.org)",
276
+ );
277
+ // Orgless BOE keeps the per-guide shape (icon + shortName + ops).
278
+ expect(catalog).toContain("⚖️ BOE — boe.es, www.boe.es");
279
+ expect(catalog).not.toContain("🏛️ BOE");
280
+ // Footer mentions the disambiguation menu.
281
+ expect(catalog).toContain("disambiguation menu");
282
+ } finally {
283
+ rmSync(dir, { recursive: true, force: true });
284
+ }
285
+ });
286
+
287
+ it("returns empty result for a nonexistent directory", () => {
288
+ const loaded = loadApiGuidesFromDir(
289
+ join(tmpdir(), "host-guides-nonexistent-xyz"),
290
+ );
291
+ expect(loaded.guides).toEqual({});
292
+ expect(loaded.malformed).toEqual([]);
293
+ expect(formatApiGuideCatalog(loaded)).toContain("no guides");
294
+ });
295
+
296
+ it("skips subdirectories without guide.md", () => {
297
+ const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
298
+ try {
299
+ // A subdir without guide.md — skipped
300
+ mkdirSync(join(dir, "no-guide"), { recursive: true });
301
+ writeFileSync(join(dir, "no-guide", "helper.ts"), "export default p => p;");
302
+
303
+ // A valid subdir with guide.md — loaded
304
+ mkdirSync(join(dir, "boe"), { recursive: true });
305
+ writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
306
+
307
+ // Flat .md files at top level — ignored
308
+ writeFileSync(join(dir, "README.txt"), "not a guide");
309
+
310
+ const loaded = loadApiGuidesFromDir(dir);
311
+ expect(Object.keys(loaded.guides)).toEqual(["boe"]);
312
+ } finally {
313
+ rmSync(dir, { recursive: true, force: true });
314
+ }
315
+ });
316
+
317
+ it("routes a divergent folder (entry !== slug(shortName)) to malformed", () => {
318
+ const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
319
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
320
+ try {
321
+ // BOE_RECIPE has shortName: BOE → slug "boe"; folder is "boe.es" —
322
+ // the pre-migration state. Under enforcement the guide does NOT load.
323
+ mkdirSync(join(dir, "boe.es"), { recursive: true });
324
+ writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
325
+
326
+ const loaded = loadApiGuidesFromDir(dir);
327
+ expect(Object.keys(loaded.guides)).toEqual([]);
328
+ expect(loaded.malformed).toHaveLength(1);
329
+ expect(loaded.malformed[0]!.filename).toBe("boe.es");
330
+ expect(loaded.malformed[0]!.error.field).toBe("shortName");
331
+ expect(loaded.malformed[0]!.error.found).toBe("folder 'boe.es'");
332
+ expect(loaded.malformed[0]!.error.fix).toContain("mv");
333
+ expect(loaded.malformed[0]!.error.fix).toContain("boe");
334
+ // The per-guide fix names the mv only; no /reload instruction on the
335
+ // fix line itself.
336
+ expect(loaded.malformed[0]!.error.fix).not.toContain("/reload");
337
+ // The malformed guide is warned about at load, and the catalog
338
+ // renders its actionable fix.
339
+ const msg = warn.mock.calls.map((c) => String(c[0])).join("\n");
340
+ expect(msg).toContain("Malformed guide");
341
+ expect(msg).toContain("boe.es");
342
+ const catalog = formatApiGuideCatalog(loaded);
343
+ expect(catalog).toContain("fix:");
344
+ expect(catalog).not.toContain("/reload");
345
+ } finally {
346
+ warn.mockRestore();
347
+ rmSync(dir, { recursive: true, force: true });
348
+ }
349
+ });
350
+
351
+ it("routes warnings through the notify callback when provided", () => {
352
+ const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
353
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
354
+ const notify = vi.fn();
355
+ try {
356
+ // Divergent folder (boe.es vs slug "boe") — must surface loudly.
357
+ mkdirSync(join(dir, "boe.es"), { recursive: true });
358
+ writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
359
+
360
+ const loaded = loadApiGuidesFromDir(dir, notify);
361
+ expect(Object.keys(loaded.guides)).toEqual([]);
362
+ expect(loaded.malformed).toHaveLength(1);
363
+ // The per-guide warning goes through notify, not console.warn.
364
+ expect(notify).toHaveBeenCalled();
365
+ expect(warn).not.toHaveBeenCalled();
366
+ const msgs = notify.mock.calls.map((c) => String(c[0])).join("\n");
367
+ expect(msgs).toContain("Malformed guide");
368
+ expect(msgs).toContain("boe.es");
369
+ // Every notify call uses the warning kind (ctx.ui.notify signature).
370
+ expect(notify.mock.calls.every((c) => c[1] === "warning")).toBe(true);
371
+ } finally {
372
+ warn.mockRestore();
373
+ rmSync(dir, { recursive: true, force: true });
374
+ }
375
+ });
376
+
377
+ it("a divergent + convergent pair sharing shortName loads only the convergent one", () => {
378
+ const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
379
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
380
+ try {
381
+ // Old divergent folder (pre-migration) + new convergent folder
382
+ // (slug). Only the convergent one loads.
383
+ mkdirSync(join(dir, "boe.es"), { recursive: true });
384
+ writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
385
+ mkdirSync(join(dir, "boe"), { recursive: true });
386
+ writeFileSync(join(dir, "boe", "guide.md"), BOE_RECIPE);
387
+
388
+ const loaded = loadApiGuidesFromDir(dir);
389
+ expect(Object.keys(loaded.guides)).toEqual(["boe"]);
390
+ expect(loaded.malformed).toHaveLength(1);
391
+ } finally {
392
+ warn.mockRestore();
393
+ rmSync(dir, { recursive: true, force: true });
394
+ }
395
+ });
396
+
397
+ it("routes an empty/all-symbol shortName to malformed without throwing", () => {
398
+ const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
399
+ try {
400
+ for (const bad of ["'!!!'", "''"]) {
401
+ mkdirSync(join(dir, "bad"), { recursive: true });
402
+ writeFileSync(
403
+ join(dir, "bad", "guide.md"),
404
+ BOE_RECIPE.replace("shortName: BOE", `shortName: ${bad}`),
405
+ );
406
+
407
+ const loaded = loadApiGuidesFromDir(dir);
408
+ expect(Object.keys(loaded.guides)).toEqual([]);
409
+ expect(loaded.malformed).toHaveLength(1);
410
+ expect(loaded.malformed[0]!.filename).toBe("bad");
411
+ expect(loaded.malformed[0]!.error.field).toBe("shortName");
412
+ expect(loaded.malformed[0]!.error.fix).toContain("shortName");
413
+ rmSync(join(dir, "bad"), { recursive: true, force: true });
414
+ }
415
+ } finally {
416
+ rmSync(dir, { recursive: true, force: true });
417
+ }
418
+ });
419
+
420
+ it("a divergent guide loads after an agent-assisted rename to slug(shortName)", () => {
421
+ const dir = mkdtempSync(join(tmpdir(), "host-guides-"));
422
+ try {
423
+ // BOE_RECIPE has shortName: BOE → slug "boe"; folder is "boe.es" —
424
+ // the pre-migration state routes to malformed.
425
+ mkdirSync(join(dir, "boe.es"), { recursive: true });
426
+ writeFileSync(join(dir, "boe.es", "guide.md"), BOE_RECIPE);
427
+ let loaded = loadApiGuidesFromDir(dir);
428
+ expect(Object.keys(loaded.guides)).toEqual([]);
429
+ expect(loaded.malformed).toHaveLength(1);
430
+
431
+ // The migration instruction (mv boe.es boe); /reload lives in the banner.
432
+ renameSync(join(dir, "boe.es"), join(dir, "boe"));
433
+ loaded = loadApiGuidesFromDir(dir);
434
+ expect(Object.keys(loaded.guides)).toEqual(["boe"]);
435
+ expect(loaded.malformed).toEqual([]);
436
+ } finally {
437
+ rmSync(dir, { recursive: true, force: true });
438
+ }
439
+ });
440
+ });
441
+
442
+ // ═══════════════════════════════════════════════════════════════════
443
+ // stampFrontmatterField — save-stamp blank-line separation (G3)
444
+ // ═══════════════════════════════════════════════════════════════════
445
+
446
+ describe("stampFrontmatterField", () => {
447
+ it("inserts a blank line before a new key when the preceding line is non-empty", () => {
448
+ const out = stampFrontmatterField(
449
+ "---\nfoo: bar\n---\n",
450
+ "schemaVersion",
451
+ "0",
452
+ );
453
+ expect(out).toBe("---\nfoo: bar\n\nschemaVersion: 0\n---\n");
454
+ });
455
+
456
+ it("does not double-blank when the preceding line is already empty", () => {
457
+ const out = stampFrontmatterField(
458
+ "---\nfoo: bar\n\n---\n",
459
+ "schemaVersion",
460
+ "0",
461
+ );
462
+ expect(out).toBe("---\nfoo: bar\n\nschemaVersion: 0\n---\n");
463
+ });
464
+
465
+ it("replaces an existing key without introducing a blank line (idempotent re-stamp)", () => {
466
+ const out = stampFrontmatterField(
467
+ "---\nfoo: bar\nschemaVersion: 0\n---\n",
468
+ "schemaVersion",
469
+ "1",
470
+ );
471
+ expect(out).toBe("---\nfoo: bar\nschemaVersion: 1\n---\n");
472
+ });
473
+
474
+ it("replaces a valueless key line instead of duplicating it", () => {
475
+ // A bare `schemaVersion:` would otherwise survive the replace and
476
+ // collide with the appended stamp (duplicate YAML key).
477
+ const out = stampFrontmatterField(
478
+ "---\nfoo: bar\nschemaVersion:\n---\n",
479
+ "schemaVersion",
480
+ "1",
481
+ );
482
+ expect(out).toBe("---\nfoo: bar\nschemaVersion: 1\n---\n");
483
+ });
484
+ });
@@ -24,8 +24,6 @@ import {
24
24
  normalizeDateParam,
25
25
  type RestGetResult,
26
26
  } from "../core/helpers.js";
27
- import { ssrfGuard } from "../core/ssrf-guard.js";
28
- import { fetchUrl } from "../core/transport.js";
29
27
  import type { ApiGuide, Operation } from "../core/api-guide-types.js";
30
28
 
31
29
  // ═══════════════════════════════════════════════════════════════════
@@ -196,19 +194,6 @@ async function createTestServer(): Promise<TestContext> {
196
194
  return;
197
195
  }
198
196
 
199
- // 302 redirect to the cloud metadata endpoint — used by the
200
- // fetchUrl guardRedirects test (M3). The initial URL is on the
201
- // test server (127.0.0.1); fetchUrl does NOT ssrf-check the
202
- // initial URL, only redirect targets, so this isolates the
203
- // redirect-guard behaviour from paginate's pre-fetch guard.
204
- if (pathname === "/redirect-to-metadata") {
205
- res.writeHead(302, {
206
- Location: "http://169.254.169.254/latest/meta-data/",
207
- });
208
- res.end();
209
- return;
210
- }
211
-
212
197
  if (pathname === "/api/paginate/cursor") {
213
198
  const cursor = url.searchParams.get("cursor") ?? "";
214
199
  if (cursor === "done") {
@@ -459,24 +444,6 @@ async function createTestServer(): Promise<TestContext> {
459
444
  return;
460
445
  }
461
446
 
462
- if (pathname === "/api/latin1-no-charset") {
463
- // ISO-8859-1 bytes for áéíóú, served with NO charset parameter —
464
- // the transport must fall back to the caller's fallbackCharset.
465
- res.writeHead(200, { "Content-Type": "application/json" });
466
- res.end(Buffer.from([0xe1, 0xe9, 0xed, 0xf3, 0xfa]));
467
- return;
468
- }
469
-
470
- if (pathname === "/api/utf8-with-charset") {
471
- // Real UTF-8 bytes for áéíóú, served WITH charset=utf-8 — the
472
- // header charset must win even if a fallbackCharset is supplied.
473
- res.writeHead(200, {
474
- "Content-Type": "application/json; charset=utf-8",
475
- });
476
- res.end(Buffer.from("áéíóú", "utf-8"));
477
- return;
478
- }
479
-
480
447
  if (pathname === "/api/cache-test") {
481
448
  const count = requestCounts.get(pathname) ?? 0;
482
449
  res.writeHead(200, {
@@ -2145,105 +2112,6 @@ describe("errorPath — 200-with-error-envelope", () => {
2145
2112
  // runs passing — pinned here once, not re-tested in verify-command.
2146
2113
  });
2147
2114
 
2148
- // ═══════════════════════════════════════════════════════════════════
2149
- // SSRF guard (M1) — IPv4-mapped IPv6 bypass + baseline blocks
2150
- // ═══════════════════════════════════════════════════════════════════
2151
-
2152
- describe("ssrfGuard — IPv4-mapped IPv6 (M1)", () => {
2153
- it("blocks IPv4-mapped loopback in hex form", () => {
2154
- // Node renders http://[::ffff:127.0.0.1]/ as "[::ffff:7f00:1]" —
2155
- // the old decimal "::ffff:127" check never matched this.
2156
- expect(ssrfGuard("http://[::ffff:127.0.0.1]/").ok).toBe(false);
2157
- });
2158
-
2159
- it("blocks IPv4-mapped private + metadata ranges", () => {
2160
- expect(ssrfGuard("http://[::ffff:10.0.0.1]/").ok).toBe(false);
2161
- expect(ssrfGuard("http://[::ffff:192.168.1.1]/").ok).toBe(false);
2162
- expect(ssrfGuard("http://[::ffff:169.254.169.254]/").ok).toBe(false);
2163
- });
2164
-
2165
- it("still blocks plain IPv4 private/metadata/loopback", () => {
2166
- expect(ssrfGuard("http://127.0.0.1/").ok).toBe(false);
2167
- expect(ssrfGuard("http://10.0.0.1/").ok).toBe(false);
2168
- expect(ssrfGuard("http://169.254.169.254/").ok).toBe(false);
2169
- });
2170
-
2171
- it("allows public hostnames", () => {
2172
- expect(ssrfGuard("https://api.example.com/v1/foo").ok).toBe(true);
2173
- });
2174
- });
2175
-
2176
- // ═══════════════════════════════════════════════════════════════════
2177
- // fetchUrl guardRedirects (M3) — redirect-target SSRF guarding
2178
- // ═══════════════════════════════════════════════════════════════════
2179
-
2180
- describe("fetchUrl — fallbackCharset", () => {
2181
- let ctx: TestContext;
2182
-
2183
- beforeAll(async () => {
2184
- ctx = await createTestServer();
2185
- });
2186
- afterAll(async () => {
2187
- await ctx.stop();
2188
- });
2189
-
2190
- it("falls back to fallbackCharset when the response omits a charset", async () => {
2191
- // Server serves ISO-8859-1 bytes with no charset parameter.
2192
- const { body } = await fetchUrl(`${ctx.serverUrl}/api/latin1-no-charset`, {
2193
- fallbackCharset: "iso-8859-1",
2194
- fresh: true,
2195
- });
2196
- expect(body).toBe("áéíóú");
2197
- });
2198
-
2199
- it("uses utf-8 by default when no fallbackCharset is supplied", async () => {
2200
- const { body } = await fetchUrl(`${ctx.serverUrl}/api/latin1-no-charset`, {
2201
- fresh: true,
2202
- });
2203
- expect(body).not.toBe("áéíóú");
2204
- expect(body).toBe("�����");
2205
- });
2206
-
2207
- it("header charset wins over fallbackCharset", async () => {
2208
- // Server declares charset=utf-8; supplying a latin-1 fallback must
2209
- // NOT override it — the header charset always wins.
2210
- const { body } = await fetchUrl(`${ctx.serverUrl}/api/utf8-with-charset`, {
2211
- fallbackCharset: "iso-8859-1",
2212
- fresh: true,
2213
- });
2214
- expect(body).toBe("áéíóú");
2215
- });
2216
- });
2217
-
2218
- describe("fetchUrl — guardRedirects (M3)", () => {
2219
- let ctx: TestContext;
2220
-
2221
- beforeAll(async () => {
2222
- ctx = await createTestServer();
2223
- });
2224
-
2225
- afterAll(async () => {
2226
- await ctx.stop();
2227
- });
2228
-
2229
- it("blocks a 302 redirect to the cloud metadata endpoint", async () => {
2230
- // fetchUrl does NOT ssrf-check the initial URL (paginate owns that),
2231
- // so hitting the 127.0.0.1 test server is fine. The redirect target
2232
- // (169.254.169.254) must be blocked before it is fetched.
2233
- const before = ctx.requestCounts.get("/redirect-to-metadata") ?? 0;
2234
- await expect(
2235
- fetchUrl(`${ctx.serverUrl}/redirect-to-metadata`, {
2236
- guardRedirects: true,
2237
- }),
2238
- ).rejects.toThrow(/Redirect to blocked host/i);
2239
-
2240
- // The redirect endpoint was hit exactly once (the SSRF block is not
2241
- // transient, so fetchUrl must not retry).
2242
- const after = ctx.requestCounts.get("/redirect-to-metadata") ?? 0;
2243
- expect(after - before).toBe(1);
2244
- });
2245
- });
2246
-
2247
2115
  // ═══════════════════════════════════════════════════════════════════
2248
2116
  // listStyle — multi-value query params (serializer widening)
2249
2117
  // ═══════════════════════════════════════════════════════════════════