@stigmer/plugin-package 3.15.3-dev.20260916211208

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 (173) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +66 -0
  3. package/detect.d.ts +50 -0
  4. package/detect.d.ts.map +1 -0
  5. package/detect.js +164 -0
  6. package/detect.js.map +1 -0
  7. package/dialects/claude.d.ts +30 -0
  8. package/dialects/claude.d.ts.map +1 -0
  9. package/dialects/claude.js +71 -0
  10. package/dialects/claude.js.map +1 -0
  11. package/dialects/codex.d.ts +18 -0
  12. package/dialects/codex.d.ts.map +1 -0
  13. package/dialects/codex.js +19 -0
  14. package/dialects/codex.js.map +1 -0
  15. package/dialects/cursor.d.ts +23 -0
  16. package/dialects/cursor.d.ts.map +1 -0
  17. package/dialects/cursor.js +63 -0
  18. package/dialects/cursor.js.map +1 -0
  19. package/dialects/manifest.d.ts +109 -0
  20. package/dialects/manifest.d.ts.map +1 -0
  21. package/dialects/manifest.js +194 -0
  22. package/dialects/manifest.js.map +1 -0
  23. package/dialects/open.d.ts +18 -0
  24. package/dialects/open.d.ts.map +1 -0
  25. package/dialects/open.js +47 -0
  26. package/dialects/open.js.map +1 -0
  27. package/documents.d.ts +43 -0
  28. package/documents.d.ts.map +1 -0
  29. package/documents.js +111 -0
  30. package/documents.js.map +1 -0
  31. package/files.d.ts +114 -0
  32. package/files.d.ts.map +1 -0
  33. package/files.js +187 -0
  34. package/files.js.map +1 -0
  35. package/frontmatter.d.ts +51 -0
  36. package/frontmatter.d.ts.map +1 -0
  37. package/frontmatter.js +61 -0
  38. package/frontmatter.js.map +1 -0
  39. package/index.d.ts +19 -0
  40. package/index.d.ts.map +1 -0
  41. package/index.js +17 -0
  42. package/index.js.map +1 -0
  43. package/messages.d.ts +39 -0
  44. package/messages.d.ts.map +1 -0
  45. package/messages.js +135 -0
  46. package/messages.js.map +1 -0
  47. package/normalise/ignored.d.ts +24 -0
  48. package/normalise/ignored.d.ts.map +1 -0
  49. package/normalise/ignored.js +68 -0
  50. package/normalise/ignored.js.map +1 -0
  51. package/normalise/mcp-servers.d.ts +47 -0
  52. package/normalise/mcp-servers.d.ts.map +1 -0
  53. package/normalise/mcp-servers.js +397 -0
  54. package/normalise/mcp-servers.js.map +1 -0
  55. package/normalise/overlay.d.ts +27 -0
  56. package/normalise/overlay.d.ts.map +1 -0
  57. package/normalise/overlay.js +67 -0
  58. package/normalise/overlay.js.map +1 -0
  59. package/normalise/skills.d.ts +31 -0
  60. package/normalise/skills.d.ts.map +1 -0
  61. package/normalise/skills.js +116 -0
  62. package/normalise/skills.js.map +1 -0
  63. package/normalise/sub-agents.d.ts +44 -0
  64. package/normalise/sub-agents.d.ts.map +1 -0
  65. package/normalise/sub-agents.js +168 -0
  66. package/normalise/sub-agents.js.map +1 -0
  67. package/normalise/variables.d.ts +27 -0
  68. package/normalise/variables.d.ts.map +1 -0
  69. package/normalise/variables.js +155 -0
  70. package/normalise/variables.js.map +1 -0
  71. package/outcome.d.ts +46 -0
  72. package/outcome.d.ts.map +1 -0
  73. package/outcome.js +21 -0
  74. package/outcome.js.map +1 -0
  75. package/package.json +40 -0
  76. package/placeholders.d.ts +41 -0
  77. package/placeholders.d.ts.map +1 -0
  78. package/placeholders.js +68 -0
  79. package/placeholders.js.map +1 -0
  80. package/read-plugin-package.d.ts +19 -0
  81. package/read-plugin-package.d.ts.map +1 -0
  82. package/read-plugin-package.js +67 -0
  83. package/read-plugin-package.js.map +1 -0
  84. package/src/__test-utils__/directory-files.ts +29 -0
  85. package/src/__test-utils__/read.ts +55 -0
  86. package/src/__tests__/adversarial.test.ts +434 -0
  87. package/src/__tests__/detect.test.ts +133 -0
  88. package/src/__tests__/files.test.ts +119 -0
  89. package/src/__tests__/fixtures/cursor-plugins/NOTICE +22 -0
  90. package/src/__tests__/fixtures/cursor-plugins/advisor/.cursor-plugin/plugin.json +33 -0
  91. package/src/__tests__/fixtures/cursor-plugins/advisor/CHANGELOG.md +8 -0
  92. package/src/__tests__/fixtures/cursor-plugins/advisor/LICENSE +21 -0
  93. package/src/__tests__/fixtures/cursor-plugins/advisor/README.md +87 -0
  94. package/src/__tests__/fixtures/cursor-plugins/advisor/agents/advisor-subagent.md +48 -0
  95. package/src/__tests__/fixtures/cursor-plugins/advisor/assets/avatar.png +0 -0
  96. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/capture-response.sh +20 -0
  97. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/hooks.json +27 -0
  98. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/lib.sh +61 -0
  99. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/mark-pending.sh +27 -0
  100. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/record-consult.sh +41 -0
  101. package/src/__tests__/fixtures/cursor-plugins/advisor/hooks/stop-hook.sh +48 -0
  102. package/src/__tests__/fixtures/cursor-plugins/advisor/skills/advisor/SKILL.md +123 -0
  103. package/src/__tests__/fixtures/cursor-plugins/advisor/skills/advisor/references/briefing-template.md +44 -0
  104. package/src/__tests__/fixtures/cursor-plugins/github/.cursor-plugin/plugin.json +45 -0
  105. package/src/__tests__/fixtures/cursor-plugins/github/CHANGELOG.md +9 -0
  106. package/src/__tests__/fixtures/cursor-plugins/github/LICENSE +21 -0
  107. package/src/__tests__/fixtures/cursor-plugins/github/README.md +64 -0
  108. package/src/__tests__/fixtures/cursor-plugins/github/assets/logo.svg +0 -0
  109. package/src/__tests__/fixtures/cursor-plugins/github/mcp.json +11 -0
  110. package/src/__tests__/fixtures/cursor-plugins/playwright/.cursor-plugin/plugin.json +35 -0
  111. package/src/__tests__/fixtures/cursor-plugins/playwright/CHANGELOG.md +8 -0
  112. package/src/__tests__/fixtures/cursor-plugins/playwright/LICENSE +21 -0
  113. package/src/__tests__/fixtures/cursor-plugins/playwright/README.md +46 -0
  114. package/src/__tests__/fixtures/cursor-plugins/playwright/assets/logo.svg +0 -0
  115. package/src/__tests__/fixtures/cursor-plugins/playwright/mcp.json +8 -0
  116. package/src/__tests__/fixtures/cursor-plugins/salesforce/.cursor-plugin/plugin.json +48 -0
  117. package/src/__tests__/fixtures/cursor-plugins/salesforce/CHANGELOG.md +10 -0
  118. package/src/__tests__/fixtures/cursor-plugins/salesforce/LICENSE +21 -0
  119. package/src/__tests__/fixtures/cursor-plugins/salesforce/README.md +95 -0
  120. package/src/__tests__/fixtures/cursor-plugins/salesforce/assets/logo.svg +0 -0
  121. package/src/__tests__/fixtures/cursor-plugins/salesforce/mcp.json +12 -0
  122. package/src/__tests__/fixtures/cursor-plugins/thermos/.cursor-plugin/plugin.json +32 -0
  123. package/src/__tests__/fixtures/cursor-plugins/thermos/CHANGELOG.md +8 -0
  124. package/src/__tests__/fixtures/cursor-plugins/thermos/LICENSE +21 -0
  125. package/src/__tests__/fixtures/cursor-plugins/thermos/README.md +70 -0
  126. package/src/__tests__/fixtures/cursor-plugins/thermos/agents/thermo-nuclear-code-quality-review-subagent.md +23 -0
  127. package/src/__tests__/fixtures/cursor-plugins/thermos/agents/thermo-nuclear-review-subagent.md +28 -0
  128. package/src/__tests__/fixtures/cursor-plugins/thermos/assets/logo.png +0 -0
  129. package/src/__tests__/fixtures/cursor-plugins/thermos/skills/thermo-nuclear-code-quality-review/SKILL.md +192 -0
  130. package/src/__tests__/fixtures/cursor-plugins/thermos/skills/thermo-nuclear-review/SKILL.md +51 -0
  131. package/src/__tests__/fixtures/cursor-plugins/thermos/skills/thermos/SKILL.md +21 -0
  132. package/src/__tests__/fixtures/cursor-plugins/xero/.cursor-plugin/plugin.json +53 -0
  133. package/src/__tests__/fixtures/cursor-plugins/xero/CHANGELOG.md +9 -0
  134. package/src/__tests__/fixtures/cursor-plugins/xero/LICENSE +21 -0
  135. package/src/__tests__/fixtures/cursor-plugins/xero/README.md +79 -0
  136. package/src/__tests__/fixtures/cursor-plugins/xero/assets/logo.png +0 -0
  137. package/src/__tests__/fixtures/cursor-plugins/xero/mcp.json +16 -0
  138. package/src/__tests__/fixtures.test.ts +198 -0
  139. package/src/__tests__/mcp-servers.test.ts +120 -0
  140. package/src/__tests__/overlay-and-ignored.test.ts +65 -0
  141. package/src/__tests__/skills.test.ts +89 -0
  142. package/src/__tests__/sub-agents.test.ts +111 -0
  143. package/src/__tests__/variables.test.ts +94 -0
  144. package/src/detect.ts +189 -0
  145. package/src/dialects/claude.ts +90 -0
  146. package/src/dialects/codex.ts +24 -0
  147. package/src/dialects/cursor.ts +73 -0
  148. package/src/dialects/manifest.ts +237 -0
  149. package/src/dialects/open.ts +48 -0
  150. package/src/documents.ts +145 -0
  151. package/src/files.ts +213 -0
  152. package/src/frontmatter.ts +70 -0
  153. package/src/index.ts +59 -0
  154. package/src/messages.ts +206 -0
  155. package/src/normalise/ignored.ts +70 -0
  156. package/src/normalise/mcp-servers.ts +427 -0
  157. package/src/normalise/overlay.ts +71 -0
  158. package/src/normalise/skills.ts +126 -0
  159. package/src/normalise/sub-agents.ts +184 -0
  160. package/src/normalise/variables.ts +161 -0
  161. package/src/outcome.ts +122 -0
  162. package/src/placeholders.ts +74 -0
  163. package/src/read-plugin-package.ts +74 -0
  164. package/src/testing.ts +258 -0
  165. package/src/types.ts +189 -0
  166. package/testing.d.ts +106 -0
  167. package/testing.d.ts.map +1 -0
  168. package/testing.js +182 -0
  169. package/testing.js.map +1 -0
  170. package/types.d.ts +152 -0
  171. package/types.d.ts.map +1 -0
  172. package/types.js +19 -0
  173. package/types.js.map +1 -0
@@ -0,0 +1,434 @@
1
+ /**
2
+ * The adversarial suite: one fixture per finding kind, each a valid plugin
3
+ * with exactly one thing broken, asserting the COMPLETE sorted list of
4
+ * kinds the reader produced (errors and warnings both) and the sentence of
5
+ * the kind under test. A warning that legitimately accompanies an error is
6
+ * named in its case, never tolerated by omission.
7
+ *
8
+ * The two tables are `Record`s over the kind unions, so a kind added to
9
+ * `outcome.ts` without a case here does not compile, and a case for a kind
10
+ * that no longer exists does not either. Together with `messages.ts` this
11
+ * is the contract: every kind, one sentence, one fixture.
12
+ */
13
+
14
+ import { describe, expect, it } from "vitest";
15
+
16
+ import { PLUGIN_DOCUMENT_LIMITS } from "../files.js";
17
+ import type { PluginErrorKind, PluginWarningKind } from "../outcome.js";
18
+ import { claudePlugin, cursorPlugin, openPlugin, type PluginFixture, withFile, withManifestField, withoutFile } from "../testing.js";
19
+ import { findingOf, type Kinds, kindsOf, read } from "../__test-utils__/read.js";
20
+
21
+ interface Case {
22
+ readonly files: PluginFixture;
23
+ /** Every kind the read produces, sorted. */
24
+ readonly kinds: Kinds;
25
+ /** The sentence of the kind under test. */
26
+ readonly message: string;
27
+ }
28
+
29
+ const OPEN_MANIFEST_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
30
+ const OPEN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
31
+ const HTTP = { type: "streamable-http", url: "https://mcp.example.com/mcp" } as const;
32
+ const CURSOR_HTTP = { type: "http", url: "https://mcp.example.com/mcp" } as const;
33
+
34
+ const errors = (...kinds: PluginErrorKind[]): Kinds => ({ errors: [...kinds].sort(), warnings: [] });
35
+ const withWarnings = (kinds: Kinds, ...warnings: PluginWarningKind[]): Kinds => ({ errors: kinds.errors, warnings: [...warnings].sort() });
36
+
37
+ const ERROR_CASES: Record<PluginErrorKind, Case> = {
38
+ "no-manifest": {
39
+ files: new Map([["README.md", "no manifest here"]]),
40
+ kinds: errors("no-manifest"),
41
+ message: "no plugin manifest found: expected one of 'plugin.json', '.claude-plugin/plugin.json', '.cursor-plugin/plugin.json', '.codex-plugin/plugin.json'",
42
+ },
43
+ "manifest-unreadable": {
44
+ files: new Map([["plugin.json", "{ oops"]]),
45
+ kinds: errors("manifest-unreadable"),
46
+ message: /^manifest 'plugin.json' is not valid JSON: /.source,
47
+ },
48
+ "manifest-schema-missing": {
49
+ files: openPlugin({ schema: null }),
50
+ kinds: errors("manifest-schema-missing"),
51
+ message: `manifest 'plugin.json' is missing the required '$schema' field; an Agent Plugins manifest declares '${OPEN_MANIFEST_SCHEMA}'`,
52
+ },
53
+ "manifest-schema-unsupported": {
54
+ files: openPlugin({ schema: "https://agent-plugins.org/schemas/9.0.0/plugin.schema.json" }),
55
+ kinds: errors("manifest-schema-unsupported"),
56
+ message: `manifest 'plugin.json' declares an unsupported '$schema' 'https://agent-plugins.org/schemas/9.0.0/plugin.schema.json'; this reader supports '${OPEN_MANIFEST_SCHEMA}'`,
57
+ },
58
+ "manifest-name-missing": {
59
+ files: withManifestField(openPlugin(), "plugin.json", "name", undefined),
60
+ kinds: errors("manifest-name-missing"),
61
+ message: "manifest 'plugin.json' is missing the required 'name' field",
62
+ },
63
+ "manifest-name-invalid": {
64
+ files: openPlugin({ name: "Has--Double" }),
65
+ kinds: errors("manifest-name-invalid"),
66
+ message: "plugin name 'Has--Double' in 'plugin.json' is invalid: 1 to 64 characters of lowercase letters, digits, hyphens and periods, starting and ending alphanumeric, with no '--' or '..'",
67
+ },
68
+ "manifest-name-conflict": {
69
+ files: new Map([...openPlugin({ name: "one" }), ...claudePlugin({ name: "two" })]),
70
+ kinds: errors("manifest-name-conflict"),
71
+ message: "manifests disagree on the plugin name: 'one' in 'plugin.json' versus 'two' in '.claude-plugin/plugin.json'",
72
+ },
73
+ "manifest-field-type": {
74
+ files: openPlugin({ manifest: { version: 3 } }),
75
+ kinds: errors("manifest-field-type"),
76
+ message: "field 'version' in 'plugin.json' has the wrong type: expected a string",
77
+ },
78
+
79
+ "path-not-relative": {
80
+ files: cursorPlugin({ manifest: { skills: "skills/" } }),
81
+ kinds: errors("path-not-relative"),
82
+ message: "path 'skills/' in '.cursor-plugin/plugin.json' must be plugin-relative and begin with './'",
83
+ },
84
+ "path-escapes-root": {
85
+ files: cursorPlugin({ manifest: { agents: "./../shared/agents/" } }),
86
+ kinds: errors("path-escapes-root"),
87
+ message: "path './../shared/agents/' in '.cursor-plugin/plugin.json' escapes the plugin root",
88
+ },
89
+ "path-glob-unsupported": {
90
+ files: cursorPlugin({ manifest: { skills: "./skills/**" } }),
91
+ kinds: errors("path-glob-unsupported"),
92
+ message: "path './skills/**' in '.cursor-plugin/plugin.json' is a glob pattern; declare directories and files by path",
93
+ },
94
+ "path-uncontained": {
95
+ files: withFile(openPlugin(), "/etc/passwd", "root"),
96
+ kinds: errors("path-uncontained"),
97
+ message: "the reader listed a path outside the plugin root: '/etc/passwd'",
98
+ },
99
+ "document-too-large": {
100
+ files: withFile(openPlugin(), "skills/big/SKILL.md", `---\nname: big\ndescription: d\n---\n${"x".repeat(PLUGIN_DOCUMENT_LIMITS.skillMd)}`),
101
+ kinds: errors("document-too-large"),
102
+ message: `'skills/big/SKILL.md' is ${PLUGIN_DOCUMENT_LIMITS.skillMd + "---\nname: big\ndescription: d\n---\n".length} bytes, over the ${PLUGIN_DOCUMENT_LIMITS.skillMd}-byte limit for this kind of document`,
103
+ },
104
+
105
+ "skill-frontmatter-missing": {
106
+ files: openPlugin({ skills: [{ name: "bare", frontmatter: null }] }),
107
+ kinds: errors("skill-frontmatter-missing"),
108
+ message: "'skills/bare/SKILL.md' must start with YAML frontmatter ('---')",
109
+ },
110
+ "skill-frontmatter-unclosed": {
111
+ files: withFile(openPlugin(), "skills/open/SKILL.md", "---\nname: open\ndescription: d\nbody without a closing fence"),
112
+ kinds: errors("skill-frontmatter-unclosed"),
113
+ message: "'skills/open/SKILL.md' frontmatter is not closed (missing the closing '---')",
114
+ },
115
+ "skill-frontmatter-unreadable": {
116
+ files: withFile(openPlugin(), "skills/broken/SKILL.md", "---\nname: [unclosed\n---\nbody"),
117
+ kinds: errors("skill-frontmatter-unreadable"),
118
+ message: /^'skills\/broken\/SKILL.md' frontmatter is not valid YAML: /.source,
119
+ },
120
+ "skill-name-invalid": {
121
+ files: openPlugin({ skills: [{ name: "Bad_Name", dir: "bad", description: "d" }] }),
122
+ kinds: errors("skill-name-invalid"),
123
+ message: "skill name 'Bad_Name' in 'skills/bad/SKILL.md' is invalid: lowercase letters, digits and hyphens, optionally dot-scoped, with every segment alphanumeric",
124
+ },
125
+ "skill-name-duplicate": {
126
+ files: openPlugin({ skills: [{ name: "same", dir: "a", description: "d" }, { name: "same", dir: "b", description: "d" }] }),
127
+ kinds: withWarnings(errors("skill-name-duplicate"), "skill-name-differs-from-directory", "skill-name-differs-from-directory"),
128
+ message: "skill name 'same' appears more than once (again in 'skills/b/SKILL.md')",
129
+ },
130
+
131
+ "mcp-config-unreadable": {
132
+ files: withFile(openPlugin(), "mcp.json", "not json"),
133
+ kinds: errors("mcp-config-unreadable"),
134
+ message: /^MCP configuration 'mcp.json' is not valid JSON: /.source,
135
+ },
136
+ "mcp-config-shape": {
137
+ files: withFile(openPlugin(), "mcp.json", JSON.stringify({ $schema: OPEN_MCP_SCHEMA, mcpServers: [] })),
138
+ kinds: errors("mcp-config-shape"),
139
+ message: "MCP configuration 'mcp.json' must be an object with an 'mcpServers' object",
140
+ },
141
+ "mcp-config-schema-missing": {
142
+ files: openPlugin({ mcpServers: { s: HTTP }, mcpSchema: null }),
143
+ kinds: errors("mcp-config-schema-missing"),
144
+ message: `MCP configuration 'mcp.json' is missing the required '$schema' field; an Agent Plugins configuration declares '${OPEN_MCP_SCHEMA}'`,
145
+ },
146
+ "mcp-config-schema-unsupported": {
147
+ files: openPlugin({ mcpServers: { s: HTTP }, mcpSchema: "https://agent-plugins.org/schemas/9.0.0/mcp.schema.json" }),
148
+ kinds: errors("mcp-config-schema-unsupported"),
149
+ message: `MCP configuration 'mcp.json' declares an unsupported '$schema' 'https://agent-plugins.org/schemas/9.0.0/mcp.schema.json'; this reader supports '${OPEN_MCP_SCHEMA}'`,
150
+ },
151
+ "mcp-config-field-unknown": {
152
+ files: openPlugin({ mcpServers: { s: HTTP }, mcpConfig: { comment: "x" } }),
153
+ kinds: errors("mcp-config-field-unknown"),
154
+ message: "MCP configuration 'mcp.json' has an unexpected top-level field 'comment'; only '$schema' and 'mcpServers' are allowed",
155
+ },
156
+
157
+ "mcp-server-shape": {
158
+ files: openPlugin({ mcpServers: { s: "not-an-object" } }),
159
+ kinds: errors("mcp-server-shape"),
160
+ message: "MCP server 's' in 'mcp.json' must be an object",
161
+ },
162
+ "mcp-server-type-missing": {
163
+ files: openPlugin({ mcpServers: { s: { url: "https://mcp.example.com/mcp" } } }),
164
+ kinds: errors("mcp-server-type-missing"),
165
+ message: "MCP server 's' in 'mcp.json' is missing the 'type' field the Agent Plugins format requires ('stdio', 'streamable-http' or 'sse')",
166
+ },
167
+ "mcp-server-transport-unknown": {
168
+ files: cursorPlugin({ mcpServers: { s: { args: ["x"] } } }),
169
+ kinds: errors("mcp-server-transport-unknown"),
170
+ message: "MCP server 's' in 'mcp.json' has no 'type' and neither a 'command' nor a 'url' to infer it from",
171
+ },
172
+ "mcp-server-type-ambiguous": {
173
+ files: cursorPlugin({ mcpServers: { s: { command: "npx", url: "https://mcp.example.com/mcp" } } }),
174
+ kinds: errors("mcp-server-type-ambiguous"),
175
+ message: "MCP server 's' in 'mcp.json' declares both 'command' and 'url'; a server is either stdio or HTTP",
176
+ },
177
+ "mcp-server-type-unknown": {
178
+ files: cursorPlugin({ mcpServers: { s: { type: "websocket", url: "wss://x" } } }),
179
+ kinds: errors("mcp-server-type-unknown"),
180
+ message: "MCP server 's' in 'mcp.json' has an unknown 'type' 'websocket'; expected 'stdio', 'http', 'streamable-http' or 'sse'",
181
+ },
182
+ "mcp-server-field-unknown": {
183
+ files: openPlugin({ mcpServers: { s: { ...HTTP, env: { A: "${A}" } } } }),
184
+ kinds: errors("mcp-server-field-unknown"),
185
+ message: "MCP server 's' in 'mcp.json' has a field 'env' that its transport does not define",
186
+ },
187
+ "mcp-server-field-type": {
188
+ files: openPlugin({ mcpServers: { s: { type: "stdio", command: "npx", args: "-y" } } }),
189
+ kinds: errors("mcp-server-field-type"),
190
+ message: "MCP server 's' in 'mcp.json' field 'args' has the wrong type",
191
+ },
192
+ "mcp-server-url-missing": {
193
+ files: openPlugin({ mcpServers: { s: { type: "streamable-http" } } }),
194
+ kinds: errors("mcp-server-url-missing"),
195
+ message: "MCP server 's' in 'mcp.json' is missing the 'url' its HTTP transport requires",
196
+ },
197
+ "mcp-server-url-invalid": {
198
+ files: openPlugin({ mcpServers: { s: { type: "streamable-http", url: "http://mcp.example.com/mcp" } } }),
199
+ kinds: errors("mcp-server-url-invalid"),
200
+ message: "MCP server 's' in 'mcp.json' has an invalid 'url' 'http://mcp.example.com/mcp': expected an absolute HTTPS URL (HTTP only for localhost) with no user information or fragment",
201
+ },
202
+ "mcp-server-url-variable": {
203
+ files: cursorPlugin({ variables: { URL: { type: "string" } }, required: ["URL"], mcpServers: { s: { type: "http", url: "${URL}" } } }),
204
+ kinds: errors("mcp-server-url-variable"),
205
+ message: "MCP server 's' in 'mcp.json' has a variable in its 'url'; Stigmer sends the URL as written, so write the URL out and put variables in 'headers'",
206
+ },
207
+ "mcp-server-command-missing": {
208
+ files: openPlugin({ mcpServers: { s: { type: "stdio", args: ["x"] } } }),
209
+ kinds: errors("mcp-server-command-missing"),
210
+ message: "MCP server 's' in 'mcp.json' is missing the 'command' its stdio transport requires",
211
+ },
212
+ "mcp-server-command-invalid": {
213
+ files: openPlugin({ mcpServers: { s: { type: "stdio", command: "npx -y @acme/server" } } }),
214
+ kinds: errors("mcp-server-command-invalid"),
215
+ message: "MCP server 's' in 'mcp.json' has a 'command' that is not a single executable name; put arguments in 'args'",
216
+ },
217
+ "mcp-server-command-relative": {
218
+ files: openPlugin({ mcpServers: { s: { type: "stdio", command: "./bin/server" } } }),
219
+ kinds: errors("mcp-server-command-relative"),
220
+ message: "MCP server 's' in 'mcp.json' runs a command bundled in the plugin './bin/server'; Stigmer runs only commands on the runner's PATH (for example 'npx' or 'uvx')",
221
+ },
222
+ "mcp-server-plugin-root-reference": {
223
+ files: claudePlugin({ mcpServers: { s: { command: "npx", args: ["--config", "${CLAUDE_PLUGIN_ROOT}/config.json"] } } }),
224
+ kinds: errors("mcp-server-plugin-root-reference"),
225
+ message: "MCP server 's' in '.mcp.json' references the plugin's own files through '${CLAUDE_PLUGIN_ROOT}'; Stigmer does not mount plugin files into the runner",
226
+ },
227
+ "mcp-server-cwd-unsupported": {
228
+ files: openPlugin({ mcpServers: { s: { type: "stdio", command: "npx", cwd: "./data" } } }),
229
+ kinds: errors("mcp-server-cwd-unsupported"),
230
+ message: "MCP server 's' in 'mcp.json' sets a working directory; Stigmer does not mount plugin files, so a bundled directory cannot be reached",
231
+ },
232
+ "mcp-server-env-literal": {
233
+ files: openPlugin({ mcpServers: { s: { type: "stdio", command: "npx", env: { LOG_LEVEL: "debug" } } } }),
234
+ kinds: errors("mcp-server-env-literal"),
235
+ message: `MCP server 's' in 'mcp.json' sets environment variable 'LOG_LEVEL' to a literal value; Stigmer passes declared variables by name, so write 'LOG_LEVEL: "\${LOG_LEVEL}"' and declare the variable`,
236
+ },
237
+ "mcp-server-env-rename": {
238
+ files: openPlugin({ mcpServers: { s: { type: "stdio", command: "npx", env: { API_KEY: "${MY_KEY}" } } } }),
239
+ kinds: errors("mcp-server-env-rename"),
240
+ message: "MCP server 's' in 'mcp.json' maps environment variable 'API_KEY' to a differently named variable; Stigmer passes declared variables by name, so use the same name on both sides",
241
+ },
242
+ "mcp-server-name-duplicate": {
243
+ files: new Map([
244
+ ...openPlugin({ name: "dup", mcpServers: { s: HTTP } }),
245
+ ...claudePlugin({ name: "dup", mcpServers: { s: { url: "https://other.example.com/mcp" } } }),
246
+ ]),
247
+ kinds: errors("mcp-server-name-duplicate"),
248
+ message: "MCP server name 's' appears more than once (again in '.mcp.json')",
249
+ },
250
+ "mcp-server-header-duplicate": {
251
+ files: openPlugin({ mcpServers: { s: { ...HTTP, headers: { Authorization: "a", authorization: "b" } } } }),
252
+ kinds: errors("mcp-server-header-duplicate"),
253
+ message: "MCP server 's' in 'mcp.json' declares header 'authorization' more than once (header names are case-insensitive)",
254
+ },
255
+ "mcp-server-header-invalid": {
256
+ files: openPlugin({ mcpServers: { s: { ...HTTP, headers: { "X Bad Header": "a" } } } }),
257
+ kinds: errors("mcp-server-header-invalid"),
258
+ message: "MCP server 's' in 'mcp.json' has an invalid header name 'X Bad Header'",
259
+ },
260
+
261
+ "sub-agent-frontmatter-unreadable": {
262
+ files: withFile(cursorPlugin({ agents: [] }), "agents/broken.md", "---\nname: [unclosed\n---\nA perfectly good prompt body."),
263
+ kinds: errors("sub-agent-frontmatter-unreadable"),
264
+ message: /^'agents\/broken.md' frontmatter is not valid YAML: /.source,
265
+ },
266
+ "sub-agent-instructions-short": {
267
+ files: cursorPlugin({ agents: [{ file: "terse", frontmatter: { name: "terse" }, body: "Go.\n" }] }),
268
+ kinds: errors("sub-agent-instructions-short"),
269
+ message: "sub-agent 'terse' in 'agents/terse.md' has instructions under 10 characters; the body of the file is the sub-agent's prompt",
270
+ },
271
+ "sub-agent-name-duplicate": {
272
+ files: cursorPlugin({ agents: [{ file: "a", frontmatter: { name: "same" } }, { file: "b", frontmatter: { name: "same" } }] }),
273
+ kinds: errors("sub-agent-name-duplicate"),
274
+ message: "sub-agent name 'same' appears more than once (again in 'agents/b.md')",
275
+ },
276
+
277
+ "variable-name-invalid": {
278
+ files: cursorPlugin({ variables: { "my-token": { type: "string" } } }),
279
+ kinds: errors("variable-name-invalid"),
280
+ message: "variable name 'my-token' in '.cursor-plugin/plugin.json' is invalid: an environment variable name is letters, digits and underscores, not starting with a digit",
281
+ },
282
+
283
+ "overlay-server-unknown": {
284
+ files: openPlugin({ mcpServers: { s: HTTP }, files: { "ai.stigmer/mcp-servers/other.yaml": "spec: {}\n" } }),
285
+ kinds: errors("overlay-server-unknown"),
286
+ message: "'ai.stigmer/mcp-servers/other.yaml' overlays MCP server 'other', which the plugin does not declare",
287
+ },
288
+ "overlay-document-unknown": {
289
+ files: openPlugin({ files: { "ai.stigmer/agent.yml": "kind: Agent\n" } }),
290
+ kinds: errors("overlay-document-unknown"),
291
+ message: "'ai.stigmer/agent.yml' is not a document Stigmer reads; the 'ai.stigmer/' folder holds 'agent.yaml', 'workflows/<name>.yaml' and 'mcp-servers/<server>.yaml'",
292
+ },
293
+ };
294
+
295
+ const warnings = (...kinds: PluginWarningKind[]): Kinds => ({ errors: [], warnings: [...kinds].sort() });
296
+
297
+ const WARNING_CASES: Record<PluginWarningKind, Case> = {
298
+ "manifest-field-unknown": {
299
+ files: openPlugin({ manifest: { colour: "blue" } }),
300
+ kinds: warnings("manifest-field-unknown"),
301
+ message: "manifest 'plugin.json' has an unknown field 'colour', ignored",
302
+ },
303
+ "manifest-extensions-invalid": {
304
+ files: openPlugin({ manifest: { extensions: ["nope"] } }),
305
+ kinds: warnings("manifest-extensions-invalid"),
306
+ message: "manifest 'plugin.json' has an 'extensions' field that is not an object, ignored",
307
+ },
308
+ "path-missing": {
309
+ files: cursorPlugin({ manifest: { agents: "./agents/" } }),
310
+ kinds: warnings("path-missing"),
311
+ message: "path './agents' declared in '.cursor-plugin/plugin.json' does not exist in the plugin",
312
+ },
313
+ "skill-name-defaulted": {
314
+ files: withFile(openPlugin(), "skills/from-dir/SKILL.md", "---\ndescription: d\n---\nbody"),
315
+ kinds: warnings("skill-name-defaulted"),
316
+ message: "'skills/from-dir/SKILL.md' has no 'name' in its frontmatter; the skill is named after its directory, 'from-dir'",
317
+ },
318
+ "skill-name-differs-from-directory": {
319
+ files: openPlugin({ skills: [{ name: "real", dir: "folder", description: "d" }] }),
320
+ kinds: warnings("skill-name-differs-from-directory"),
321
+ message: "skill 'real' in 'skills/folder/SKILL.md' is named differently from its directory 'folder'",
322
+ },
323
+ "skill-description-missing": {
324
+ files: openPlugin({ skills: [{ name: "quiet" }] }),
325
+ kinds: warnings("skill-description-missing"),
326
+ message: "skill 'quiet' in 'skills/quiet/SKILL.md' has no 'description'",
327
+ },
328
+ "mcp-config-field-ignored": {
329
+ files: withFile(claudePlugin(), ".mcp.json", JSON.stringify({ mcpServers: {}, comment: "x" })),
330
+ kinds: warnings("mcp-config-field-ignored"),
331
+ message: "MCP configuration '.mcp.json' has a top-level field 'comment' Stigmer does not read, ignored",
332
+ },
333
+ "mcp-server-sse-mapped": {
334
+ files: openPlugin({ mcpServers: { legacy: { type: "sse", url: "https://legacy.example.com/sse" } } }),
335
+ kinds: warnings("mcp-server-sse-mapped"),
336
+ message: "MCP server 'legacy' in 'mcp.json' declares the legacy 'sse' transport; Stigmer connects over Streamable HTTP and falls back to SSE only when the server rejects it",
337
+ },
338
+ "mcp-server-field-ignored": {
339
+ files: cursorPlugin({ mcpServers: { s: { ...CURSOR_HTTP, timeout: 30 } } }),
340
+ kinds: warnings("mcp-server-field-ignored"),
341
+ message: "MCP server 's' in 'mcp.json' has a field 'timeout' Stigmer does not read, ignored",
342
+ },
343
+ "mcp-server-auth-ignored": {
344
+ files: cursorPlugin({ mcpServers: { s: { ...CURSOR_HTTP, auth: { scopes: ["read"] } } } }),
345
+ kinds: warnings("mcp-server-auth-ignored"),
346
+ message: "MCP server 's' in 'mcp.json' has an 'auth' block Stigmer does not read; OAuth for a server is declared in 'ai.stigmer/mcp-servers/s.yaml'",
347
+ },
348
+ "variable-inferred": {
349
+ files: cursorPlugin({ mcpServers: { s: { ...CURSOR_HTTP, headers: { Authorization: "Bearer ${TOKEN}" } } } }),
350
+ kinds: warnings("variable-inferred"),
351
+ message: "variable 'TOKEN' is referenced by MCP server 's' but not declared; it is declared as a required secret",
352
+ },
353
+ "variable-unreferenced": {
354
+ files: cursorPlugin({ variables: { UNUSED: { type: "string" } } }),
355
+ kinds: warnings("variable-unreferenced"),
356
+ message: "variable 'UNUSED' in '.cursor-plugin/plugin.json' is declared but no MCP server references it",
357
+ },
358
+ "variable-default-dropped": {
359
+ files: claudePlugin({ userConfig: { MODE: { type: "string", default: "fast" } }, mcpServers: { s: { command: "npx", args: ["${MODE}"] } } }),
360
+ kinds: warnings("variable-default-dropped"),
361
+ message: "variable 'MODE' in '.claude-plugin/plugin.json' has a default value, which Stigmer does not carry; the user supplies the value",
362
+ },
363
+ "variable-type-narrowed": {
364
+ files: claudePlugin({ userConfig: { DIR: { type: "directory" } }, mcpServers: { s: { command: "npx", args: ["${DIR}"] } } }),
365
+ kinds: warnings("variable-type-narrowed"),
366
+ message: "variable 'DIR' in '.claude-plugin/plugin.json' is typed 'directory'; Stigmer variables are strings",
367
+ },
368
+ "variable-option-dropped": {
369
+ files: claudePlugin({ userConfig: { MODE: { type: "string", options: ["a", "b"] } }, mcpServers: { s: { command: "npx", args: ["${MODE}"] } } }),
370
+ kinds: warnings("variable-option-dropped"),
371
+ message: "variable 'MODE' in '.claude-plugin/plugin.json' has a 'options' constraint, which Stigmer does not carry",
372
+ },
373
+ "sub-agent-name-defaulted": {
374
+ files: cursorPlugin({ agents: [{ file: "helper", frontmatter: null, body: "You help with everything, patiently.\n" }] }),
375
+ kinds: warnings("sub-agent-name-defaulted"),
376
+ message: "'agents/helper.md' has no 'name' in its frontmatter; the sub-agent is named after the file, 'helper'",
377
+ },
378
+ "sub-agent-skill-unknown": {
379
+ files: claudePlugin({ agents: [{ file: "a", frontmatter: { skills: ["missing"] } }] }),
380
+ kinds: warnings("sub-agent-skill-unknown"),
381
+ message: "sub-agent 'a' in 'agents/a.md' asks for skill 'missing', which the plugin does not ship",
382
+ },
383
+ "sub-agent-model-unknown": {
384
+ files: cursorPlugin({ agents: [{ file: "a", frontmatter: { model: "grok-4.6[effort=xhigh]" } }] }),
385
+ kinds: warnings("sub-agent-model-unknown"),
386
+ message: "sub-agent 'a' in 'agents/a.md' names model 'grok-4.6[effort=xhigh]', which Stigmer cannot map; the sub-agent runs on the session's model",
387
+ },
388
+ "sub-agent-field-ignored": {
389
+ files: cursorPlugin({ agents: [{ file: "a", frontmatter: { readonly: true } }] }),
390
+ kinds: warnings("sub-agent-field-ignored"),
391
+ message: "sub-agent 'a' in 'agents/a.md' has a field 'readonly' Stigmer does not read, ignored",
392
+ },
393
+ };
394
+
395
+ function expectSentence(actual: string, expected: string): void {
396
+ // A case whose sentence ends in a parser's own message pins its prefix.
397
+ if (expected.startsWith("^")) expect(actual).toMatch(new RegExp(expected));
398
+ else expect(actual).toBe(expected);
399
+ }
400
+
401
+ describe("every error kind refuses with its own sentence and nothing unexpected", () => {
402
+ it.each(Object.entries(ERROR_CASES))("%s", (kind, testCase) => {
403
+ const outcome = read(testCase.files);
404
+ expect(kindsOf(outcome)).toEqual(testCase.kinds);
405
+ expect(outcome.ok).toBe(false);
406
+ if (!outcome.ok) expectSentence(findingOf(outcome.errors, kind).message, testCase.message);
407
+ });
408
+ });
409
+
410
+ describe("every warning kind warns with its own sentence and the plugin is accepted", () => {
411
+ it.each(Object.entries(WARNING_CASES))("%s", (kind, testCase) => {
412
+ const outcome = read(testCase.files);
413
+ expect(kindsOf(outcome)).toEqual(testCase.kinds);
414
+ expect(outcome.ok).toBe(true);
415
+ expectSentence(findingOf(outcome.warnings, kind).message, testCase.message);
416
+ });
417
+ });
418
+
419
+ describe("a valid plugin in every dialect produces no findings at all", () => {
420
+ it.each([
421
+ ["open", openPlugin({ skills: [{ name: "a", description: "d" }], mcpServers: { s: HTTP } })],
422
+ ["claude", claudePlugin({ skills: [{ name: "a", description: "d" }], agents: [{ file: "b", frontmatter: { description: "x" } }] })],
423
+ ["cursor", cursorPlugin({ skills: [{ name: "a", description: "d" }], agents: [{ file: "b" }], mcpServers: { s: CURSOR_HTTP } })],
424
+ ])("%s", (_dialect, files) => {
425
+ expect(kindsOf(read(files))).toEqual({ errors: [], warnings: [] });
426
+ });
427
+
428
+ it("removing the one broken thing from an adversarial case yields a clean plugin", () => {
429
+ // The env-literal case minus its env block is the open stdio server every other stdio case starts from.
430
+ const files = withFile(ERROR_CASES["mcp-server-env-literal"].files, "mcp.json", JSON.stringify({ $schema: OPEN_MCP_SCHEMA, mcpServers: { s: { type: "stdio", command: "npx" } } }));
431
+ expect(kindsOf(read(files))).toEqual({ errors: [], warnings: [] });
432
+ expect(withoutFile(files, "mcp.json").has("mcp.json")).toBe(false);
433
+ });
434
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Pins manifest detection and identity: the root manifest wins, the vendor
3
+ * precedence without one, every present manifest contributes, name rules
4
+ * and conflicts, the open manifest's `$schema` arms, and unknown fields as
5
+ * warnings in every dialect.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+
10
+ import { isValidPluginName } from "../detect.js";
11
+ import { claudePlugin, codexPlugin, cursorPlugin, openPlugin, withManifestField } from "../testing.js";
12
+ import { accepted, findingOf, kindsOf, read, refused } from "../__test-utils__/read.js";
13
+
14
+ describe("identity and precedence", () => {
15
+ it("takes identity from the root manifest and lists it first", () => {
16
+ const files = openPlugin({ name: "tools", version: "1.2.0", description: "d", manifest: { license: "MIT", keywords: ["a"] } });
17
+ const plugin = accepted(read(files));
18
+ expect(plugin).toMatchObject({ name: "tools", version: "1.2.0", description: "d", license: "MIT", keywords: ["a"], dialect: "agent-plugins" });
19
+ expect(plugin.manifestsFound).toEqual(["plugin.json"]);
20
+ });
21
+
22
+ it("without a root manifest, Claude precedes Cursor precedes Codex", () => {
23
+ const both = new Map([...cursorPlugin({ name: "same" }), ...claudePlugin({ name: "same" })]);
24
+ expect(accepted(read(both))).toMatchObject({ dialect: "claude", manifestsFound: [".claude-plugin/plugin.json", ".cursor-plugin/plugin.json"] });
25
+ const cursorAndCodex = new Map([...codexPlugin({ name: "same" }), ...cursorPlugin({ name: "same" })]);
26
+ expect(accepted(read(cursorAndCodex)).dialect).toBe("cursor");
27
+ expect(accepted(read(codexPlugin({ name: "same" }))).dialect).toBe("codex");
28
+ });
29
+
30
+ it("a root manifest beside a vendor manifest keeps the root identity and reads the vendor's declarations", () => {
31
+ // With a root manifest present, `mcp.json` is the open format's fixed
32
+ // location and is read under its rules; the Cursor manifest contributes
33
+ // what the open format cannot say (the variables declaration).
34
+ const files = new Map([
35
+ ...openPlugin({ name: "dual", version: "2.0.0", mcpServers: { s: { type: "streamable-http", url: "https://example.com/mcp", headers: { Authorization: "Bearer ${TOKEN}" } } } }),
36
+ ...cursorPlugin({ name: "dual", version: "9.9.9", variables: { TOKEN: { type: "string" } } }),
37
+ ]);
38
+ const plugin = accepted(read(files));
39
+ expect(plugin.version).toBe("2.0.0");
40
+ expect(plugin.dialect).toBe("agent-plugins");
41
+ expect(plugin.variables.map((v) => v.name)).toEqual(["TOKEN"]);
42
+ expect(plugin.mcpServers.map((s) => s.name)).toEqual(["s"]);
43
+ });
44
+
45
+ it("a Cursor-shaped mcp.json beside a root manifest is refused under the open format's rules", () => {
46
+ const files = new Map([
47
+ ...openPlugin({ name: "dual" }),
48
+ ...cursorPlugin({ name: "dual", mcpServers: { s: { type: "http", url: "https://example.com/mcp" } } }),
49
+ ]);
50
+ expect(kindsOf(read(files)).errors).toEqual(["mcp-config-schema-missing", "mcp-server-type-unknown"]);
51
+ });
52
+
53
+ it("refuses manifests that disagree on the name", () => {
54
+ const files = new Map([...openPlugin({ name: "one" }), ...cursorPlugin({ name: "two" })]);
55
+ const finding = findingOf(refused(read(files)), "manifest-name-conflict");
56
+ expect(finding.message).toBe("manifests disagree on the plugin name: 'one' in 'plugin.json' versus 'two' in '.cursor-plugin/plugin.json'");
57
+ });
58
+
59
+ it("refuses a directory with no manifest and names the four locations", () => {
60
+ const finding = findingOf(refused(read(new Map([["README.md", "hi"]]))), "no-manifest");
61
+ expect(finding.message).toContain("'plugin.json', '.claude-plugin/plugin.json', '.cursor-plugin/plugin.json', '.codex-plugin/plugin.json'");
62
+ });
63
+ });
64
+
65
+ describe("the plugin name rule", () => {
66
+ it("accepts the open format's valid names and refuses its invalid ones", () => {
67
+ for (const name of ["my-plugin", "acme.tools", "lint3r", "a"]) expect(isValidPluginName(name), name).toBe(true);
68
+ for (const name of ["My-Plugin", "-start", "has--double", "too.many..dots", "", "a".repeat(65), "end-"]) {
69
+ expect(isValidPluginName(name), name).toBe(false);
70
+ }
71
+ });
72
+
73
+ it("refuses an invalid name with the rule spelled out", () => {
74
+ const finding = findingOf(refused(read(cursorPlugin({ name: "Bad Name" }))), "manifest-name-invalid");
75
+ expect(finding.message).toContain("'Bad Name' in '.cursor-plugin/plugin.json' is invalid");
76
+ });
77
+
78
+ it("refuses a missing name once, naming the manifest", () => {
79
+ const files = withManifestField(claudePlugin(), ".claude-plugin/plugin.json", "name", undefined);
80
+ expect(kindsOf(read(files)).errors).toEqual(["manifest-name-missing"]);
81
+ });
82
+ });
83
+
84
+ describe("the open manifest's schema", () => {
85
+ it("refuses a missing and an unsupported $schema, each with its own sentence", () => {
86
+ expect(kindsOf(read(openPlugin({ schema: null }))).errors).toEqual(["manifest-schema-missing"]);
87
+ const unsupported = findingOf(refused(read(openPlugin({ schema: "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json" }))), "manifest-schema-unsupported");
88
+ expect(unsupported.message).toContain("2.0.0");
89
+ });
90
+
91
+ it("reports an unreadable manifest once and does not also blame its missing name", () => {
92
+ const files = new Map([["plugin.json", "{ not json"]]);
93
+ expect(kindsOf(read(files)).errors).toEqual(["manifest-unreadable"]);
94
+ });
95
+
96
+ it("decodes a manifest saved with a byte-order mark", () => {
97
+ const files = openPlugin();
98
+ const text = files.get("plugin.json") as string;
99
+ files.set("plugin.json", `\ufeff${text}`);
100
+ expect(accepted(read(files)).name).toBe("example");
101
+ });
102
+ });
103
+
104
+ describe("unknown and mistyped fields", () => {
105
+ it("warns on an unknown top-level field in every dialect", () => {
106
+ expect(kindsOf(read(openPlugin({ manifest: { colour: "blue" } }))).warnings).toEqual(["manifest-field-unknown"]);
107
+ expect(kindsOf(read(claudePlugin({ manifest: { colour: "blue" } }))).warnings).toEqual(["manifest-field-unknown"]);
108
+ expect(kindsOf(read(cursorPlugin({ manifest: { colour: "blue" } }))).warnings).toEqual(["manifest-field-unknown"]);
109
+ expect(kindsOf(read(codexPlugin({ manifest: { colour: "blue" } }))).warnings).toEqual(["manifest-field-unknown"]);
110
+ });
111
+
112
+ it("does not warn on a dialect's own metadata fields", () => {
113
+ const cursor = cursorPlugin({ manifest: { displayName: "X", publisher: "Acme", category: "c", tags: ["t"], author: { name: "a", email: "a@b.c" } } });
114
+ expect(kindsOf(read(cursor))).toEqual({ errors: [], warnings: [] });
115
+ const codex = codexPlugin({ manifest: { interface: { displayName: "X" } } });
116
+ expect(kindsOf(read(codex))).toEqual({ errors: [], warnings: [] });
117
+ });
118
+
119
+ it("refuses a known field of the wrong type, naming the field", () => {
120
+ const finding = findingOf(refused(read(openPlugin({ manifest: { keywords: "not-an-array" } }))), "manifest-field-type");
121
+ expect(finding).toMatchObject({ path: "plugin.json", subject: "keywords" });
122
+ });
123
+
124
+ it("records a non-empty extension namespace as ignored and an empty ai.stigmer namespace as nothing", () => {
125
+ const plugin = accepted(read(openPlugin({ manifest: { extensions: { "com.openai": { apps: "./.app.json" }, "ai.stigmer": {} } } })));
126
+ expect(plugin.ignored).toEqual([{ kind: "extension", path: "plugin.json#extensions.com.openai" }]);
127
+ });
128
+
129
+ it("warns on a non-object extensions field and keeps reading", () => {
130
+ const outcome = read(openPlugin({ manifest: { extensions: "nope" } }));
131
+ expect(kindsOf(outcome)).toEqual({ errors: [], warnings: ["manifest-extensions-invalid"] });
132
+ });
133
+ });