@stigmer/plugin-package 3.16.0 → 3.17.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 (86) hide show
  1. package/client/archive.d.ts +27 -0
  2. package/client/archive.d.ts.map +1 -0
  3. package/client/archive.js +38 -0
  4. package/client/archive.js.map +1 -0
  5. package/client/ignore/defaults.d.ts +2 -0
  6. package/client/ignore/defaults.d.ts.map +1 -0
  7. package/client/ignore/defaults.js +140 -0
  8. package/client/ignore/defaults.js.map +1 -0
  9. package/client/ignore/match.d.ts +3 -0
  10. package/client/ignore/match.d.ts.map +1 -0
  11. package/client/ignore/match.js +166 -0
  12. package/client/ignore/match.js.map +1 -0
  13. package/client/ignore/matcher.d.ts +50 -0
  14. package/client/ignore/matcher.d.ts.map +1 -0
  15. package/client/ignore/matcher.js +116 -0
  16. package/client/ignore/matcher.js.map +1 -0
  17. package/client/ignore/pattern.d.ts +13 -0
  18. package/client/ignore/pattern.d.ts.map +1 -0
  19. package/client/ignore/pattern.js +118 -0
  20. package/client/ignore/pattern.js.map +1 -0
  21. package/client/refs.d.ts +76 -0
  22. package/client/refs.d.ts.map +1 -0
  23. package/client/refs.js +123 -0
  24. package/client/refs.js.map +1 -0
  25. package/client/select.d.ts +74 -0
  26. package/client/select.d.ts.map +1 -0
  27. package/client/select.js +121 -0
  28. package/client/select.js.map +1 -0
  29. package/client/vocabulary.d.ts +13 -0
  30. package/client/vocabulary.d.ts.map +1 -0
  31. package/client/vocabulary.js +17 -0
  32. package/client/vocabulary.js.map +1 -0
  33. package/client.d.ts +34 -0
  34. package/client.d.ts.map +1 -0
  35. package/client.js +34 -0
  36. package/client.js.map +1 -0
  37. package/files.d.ts +6 -0
  38. package/files.d.ts.map +1 -1
  39. package/files.js +6 -0
  40. package/files.js.map +1 -1
  41. package/index.d.ts +11 -6
  42. package/index.d.ts.map +1 -1
  43. package/index.js +9 -5
  44. package/index.js.map +1 -1
  45. package/marketplace/messages.d.ts +20 -0
  46. package/marketplace/messages.d.ts.map +1 -0
  47. package/marketplace/messages.js +50 -0
  48. package/marketplace/messages.js.map +1 -0
  49. package/marketplace/outcome.d.ts +72 -0
  50. package/marketplace/outcome.d.ts.map +1 -0
  51. package/marketplace/outcome.js +29 -0
  52. package/marketplace/outcome.js.map +1 -0
  53. package/marketplace/read-marketplace.d.ts +34 -0
  54. package/marketplace/read-marketplace.d.ts.map +1 -0
  55. package/marketplace/read-marketplace.js +359 -0
  56. package/marketplace/read-marketplace.js.map +1 -0
  57. package/messages.d.ts +25 -9
  58. package/messages.d.ts.map +1 -1
  59. package/messages.js +24 -8
  60. package/messages.js.map +1 -1
  61. package/outcome.d.ts +13 -6
  62. package/outcome.d.ts.map +1 -1
  63. package/package.json +8 -2
  64. package/src/__test-utils__/read.ts +4 -4
  65. package/src/__tests__/client-ignore.test.ts +133 -0
  66. package/src/__tests__/client-refs.test.ts +101 -0
  67. package/src/__tests__/client-select-archive.test.ts +152 -0
  68. package/src/__tests__/fixtures/cursor-plugins/.cursor-plugin/marketplace.json +412 -0
  69. package/src/__tests__/fixtures/cursor-plugins/NOTICE +5 -0
  70. package/src/__tests__/marketplace.test.ts +373 -0
  71. package/src/client/archive.ts +44 -0
  72. package/src/client/ignore/defaults.ts +155 -0
  73. package/src/client/ignore/match.ts +158 -0
  74. package/src/client/ignore/matcher.ts +156 -0
  75. package/src/client/ignore/pattern.ts +131 -0
  76. package/src/client/refs.ts +170 -0
  77. package/src/client/select.ts +161 -0
  78. package/src/client/vocabulary.ts +19 -0
  79. package/src/client.ts +67 -0
  80. package/src/files.ts +6 -0
  81. package/src/index.ts +21 -5
  82. package/src/marketplace/messages.ts +62 -0
  83. package/src/marketplace/outcome.ts +95 -0
  84. package/src/marketplace/read-marketplace.ts +402 -0
  85. package/src/messages.ts +34 -21
  86. package/src/outcome.ts +14 -6
@@ -0,0 +1,402 @@
1
+ /**
2
+ * The marketplace entry: a `PluginFiles` over a marketplace tree in, a
3
+ * `MarketplaceReadOutcome` out.
4
+ *
5
+ * The read is a fixed sequence, like the plugin read: locate the marketplace
6
+ * file (first present in the dialect precedence, and only that one is read;
7
+ * a tree that carries two dialects' files describes one catalogue twice);
8
+ * parse it under the marketplace cap; read the dialect's shape into the one
9
+ * normalised shape; validate names and sources; drop the entries the tree
10
+ * cannot install, each with its own warning; resolve the defaults. Findings
11
+ * are collected and the read continues, so the outcome names every problem
12
+ * at once, and the marketplace is returned only when nothing refused.
13
+ *
14
+ * Sources. Every dialect names a plugin's directory relative to the
15
+ * marketplace root (Claude and Codex with a `./` prefix, Cursor without,
16
+ * Claude optionally under `metadata.pluginRoot`); the reader accepts both
17
+ * spellings and refuses a path that escapes the root. A source that is not
18
+ * such a directory (Claude's `github`, `url`, `npm` and `git-subdir` objects,
19
+ * Codex's non-`local` sources, Cursor's remote URLs) is a form this reader
20
+ * does not fetch: the entry is dropped with `entry-source-unsupported`, and
21
+ * nothing is invented in its place.
22
+ *
23
+ * This reader does not open the plugins it lists. A consumer that wants an
24
+ * entry's own description or version reads that directory with
25
+ * `readPluginPackage`; a consumer that wants to install it hands the same
26
+ * directory to the walker it already uses. Keeping the two reads apart is
27
+ * what lets a catalogue of eighty entries be listed without eighty reads.
28
+ */
29
+
30
+ import { hasPluginManifest, isValidPluginName } from "../detect.js";
31
+ import { describeValue, isJsonObject, isStringArray, type JsonObject } from "../documents.js";
32
+ import { decodeUtf8, isContainedPath, PLUGIN_DOCUMENT_LIMITS, PluginFileIndex, type PluginFiles } from "../files.js";
33
+ import { MARKETPLACE_LOCATIONS, MarketplaceFindings } from "./messages.js";
34
+ import type { Marketplace, MarketplaceDialect, MarketplaceEntry, MarketplaceOwner, MarketplaceReadOutcome } from "./outcome.js";
35
+
36
+ /** The precedence the reader applies when a tree carries more than one marketplace file. */
37
+ const DIALECT_PRECEDENCE: readonly MarketplaceDialect[] = ["stigmer", "claude", "cursor", "codex"];
38
+
39
+ /** Codex's per-entry install policy that marks a default. */
40
+ const CODEX_INSTALLED_BY_DEFAULT = "INSTALLED_BY_DEFAULT";
41
+
42
+ /** True when the tree holds any of the four marketplace files; the CLI's routing test. */
43
+ export function hasMarketplaceFile(paths: Iterable<string>): boolean {
44
+ const locations = new Set<string>(Object.values(MARKETPLACE_LOCATIONS));
45
+ for (const path of paths) if (locations.has(path)) return true;
46
+ return false;
47
+ }
48
+
49
+ export function readMarketplace(files: PluginFiles): MarketplaceReadOutcome {
50
+ const findings = new MarketplaceFindings();
51
+ const index = new PluginFileIndex(files);
52
+
53
+ const located = locate(index);
54
+ if (located === undefined) {
55
+ findings.error("marketplace-not-found");
56
+ return refused(findings);
57
+ }
58
+ const { dialect, path } = located;
59
+
60
+ const object = readObject(index, path, findings);
61
+ if (object === undefined) return refused(findings);
62
+
63
+ const name = readName(object, path, findings);
64
+ const raw = readEntries(object, dialect, path, findings);
65
+ const entries = offeredEntries(raw, index, path, findings);
66
+ const defaults = resolveDefaults(object, raw, entries, dialect, path, findings);
67
+
68
+ if (findings.errors.length > 0 || name === undefined) return refused(findings);
69
+
70
+ const description = readDescription(object, dialect);
71
+ const owner = readOwner(object);
72
+ const marketplace: Marketplace = {
73
+ name,
74
+ ...(description !== undefined && { description }),
75
+ ...(owner !== undefined && { owner }),
76
+ dialect,
77
+ path,
78
+ plugins: entries,
79
+ defaults,
80
+ };
81
+ return { ok: true, marketplace, warnings: findings.warnings };
82
+ }
83
+
84
+ function refused(findings: MarketplaceFindings): MarketplaceReadOutcome {
85
+ return { ok: false, errors: findings.errors, warnings: findings.warnings };
86
+ }
87
+
88
+ function locate(index: PluginFileIndex): { dialect: MarketplaceDialect; path: string } | undefined {
89
+ for (const dialect of DIALECT_PRECEDENCE) {
90
+ const path = MARKETPLACE_LOCATIONS[dialect];
91
+ if (index.has(path)) return { dialect, path };
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ function readObject(index: PluginFileIndex, path: string, findings: MarketplaceFindings): JsonObject | undefined {
97
+ const limit = PLUGIN_DOCUMENT_LIMITS.marketplace;
98
+ const entry = index.entry(path);
99
+ if (entry === undefined) {
100
+ throw new Error(`marketplace file '${path}' is not listed`);
101
+ }
102
+ if (entry.size > limit) {
103
+ findings.error("marketplace-too-large", { path, subject: String(entry.size), detail: String(limit) });
104
+ return undefined;
105
+ }
106
+ const bytes = index.files.read(path);
107
+ if (bytes.length > limit) {
108
+ findings.error("marketplace-too-large", { path, subject: String(bytes.length), detail: String(limit) });
109
+ return undefined;
110
+ }
111
+ let value: unknown;
112
+ try {
113
+ value = JSON.parse(decodeUtf8(bytes));
114
+ } catch (error) {
115
+ findings.error("marketplace-unreadable", { path, detail: error instanceof Error ? error.message : String(error) });
116
+ return undefined;
117
+ }
118
+ if (!isJsonObject(value)) {
119
+ findings.error("marketplace-unreadable", { path, detail: "the document is not a JSON object" });
120
+ return undefined;
121
+ }
122
+ return value;
123
+ }
124
+
125
+ /** The marketplace's own name, validated under the plugin name rule (the same characters an install ref can carry). */
126
+ function readName(object: JsonObject, path: string, findings: MarketplaceFindings): string | undefined {
127
+ const value = object["name"];
128
+ if (value === undefined) {
129
+ findings.error("marketplace-name-missing", { path });
130
+ return undefined;
131
+ }
132
+ if (typeof value !== "string") {
133
+ findings.error("marketplace-field-type", { path, subject: "name", detail: "a string" });
134
+ return undefined;
135
+ }
136
+ if (!isValidPluginName(value)) {
137
+ findings.error("marketplace-name-invalid", { path, subject: value });
138
+ return undefined;
139
+ }
140
+ return value;
141
+ }
142
+
143
+ function readDescription(object: JsonObject, dialect: MarketplaceDialect): string | undefined {
144
+ switch (dialect) {
145
+ case "stigmer":
146
+ return optionalStringOf(object, "description");
147
+ case "claude":
148
+ case "cursor": {
149
+ const metadata = object["metadata"];
150
+ return isJsonObject(metadata) ? optionalStringOf(metadata, "description") : undefined;
151
+ }
152
+ case "codex":
153
+ // Codex's `interface.displayName` is a label for a picker, not a description.
154
+ return undefined;
155
+ default: {
156
+ const exhaustive: never = dialect;
157
+ return exhaustive;
158
+ }
159
+ }
160
+ }
161
+
162
+ function readOwner(object: JsonObject): MarketplaceOwner | undefined {
163
+ const value = object["owner"];
164
+ if (!isJsonObject(value)) return undefined;
165
+ const name = optionalStringOf(value, "name");
166
+ const email = optionalStringOf(value, "email");
167
+ const url = optionalStringOf(value, "url");
168
+ if (name === undefined && email === undefined && url === undefined) return undefined;
169
+ return {
170
+ ...(name !== undefined && { name }),
171
+ ...(email !== undefined && { email }),
172
+ ...(url !== undefined && { url }),
173
+ };
174
+ }
175
+
176
+ /** A string field or `undefined`; a field of another type is treated as absent (metadata never refuses a catalogue). */
177
+ function optionalStringOf(object: JsonObject, field: string): string | undefined {
178
+ const value = object[field];
179
+ return typeof value === "string" ? value : undefined;
180
+ }
181
+
182
+ /**
183
+ * One entry as the file states it, before the tree is consulted. `dir` is
184
+ * the resolved marketplace-relative directory, or `undefined` when the
185
+ * source is a form this reader does not fetch (the warning is recorded here
186
+ * so the entry's order in the file is kept for the sentence).
187
+ */
188
+ interface RawEntry {
189
+ readonly name: string;
190
+ readonly dir: string | undefined;
191
+ readonly description?: string;
192
+ readonly installedByDefault: boolean;
193
+ }
194
+
195
+ function readEntries(object: JsonObject, dialect: MarketplaceDialect, path: string, findings: MarketplaceFindings): readonly RawEntry[] {
196
+ const list = object["plugins"];
197
+ if (list === undefined) {
198
+ findings.error("marketplace-plugins-missing", { path });
199
+ return [];
200
+ }
201
+ if (!Array.isArray(list)) {
202
+ findings.error("marketplace-field-type", { path, subject: "plugins", detail: "an array" });
203
+ return [];
204
+ }
205
+ const pluginRoot = dialect === "claude" ? claudePluginRoot(object) : "";
206
+
207
+ const entries: RawEntry[] = [];
208
+ const seen = new Set<string>();
209
+ list.forEach((item: unknown, position: number) => {
210
+ if (!isJsonObject(item)) {
211
+ findings.error("entry-shape", { path, subject: String(position) });
212
+ return;
213
+ }
214
+ const name = item["name"];
215
+ if (name === undefined) {
216
+ findings.error("entry-name-missing", { path, subject: String(position) });
217
+ return;
218
+ }
219
+ if (typeof name !== "string" || !isValidPluginName(name)) {
220
+ findings.error("entry-name-invalid", { path, subject: describeValue(name) });
221
+ return;
222
+ }
223
+ if (seen.has(name)) {
224
+ findings.error("entry-name-duplicate", { path, subject: name });
225
+ return;
226
+ }
227
+ seen.add(name);
228
+
229
+ const description = optionalStringOf(item, "description");
230
+ entries.push({
231
+ name,
232
+ dir: resolveSource(item["source"], name, pluginRoot, path, findings),
233
+ ...(description !== undefined && { description }),
234
+ installedByDefault: dialect === "codex" && codexInstalledByDefault(item),
235
+ });
236
+ });
237
+ return entries;
238
+ }
239
+
240
+ /** Claude's `metadata.pluginRoot`, prepended to every relative source; the empty string when absent or unusable. */
241
+ function claudePluginRoot(object: JsonObject): string {
242
+ const metadata = object["metadata"];
243
+ if (!isJsonObject(metadata)) return "";
244
+ const root = optionalStringOf(metadata, "pluginRoot");
245
+ if (root === undefined) return "";
246
+ const resolved = relativeDirectory(root);
247
+ return resolved.ok ? resolved.dir : "";
248
+ }
249
+
250
+ function codexInstalledByDefault(item: JsonObject): boolean {
251
+ const policy = item["policy"];
252
+ return isJsonObject(policy) && policy["installation"] === CODEX_INSTALLED_BY_DEFAULT;
253
+ }
254
+
255
+ /**
256
+ * A source becomes a marketplace-relative directory or nothing. A string is
257
+ * a relative directory (with or without `./`) unless it is a URL; Codex's
258
+ * `{source: "local", path}` object is the same directory; every other
259
+ * object form is a remote this reader does not fetch.
260
+ */
261
+ function resolveSource(
262
+ source: unknown,
263
+ entryName: string,
264
+ pluginRoot: string,
265
+ path: string,
266
+ findings: MarketplaceFindings,
267
+ ): string | undefined {
268
+ if (source === undefined) {
269
+ findings.error("entry-source-missing", { path, subject: entryName });
270
+ return undefined;
271
+ }
272
+ let candidate: string | undefined;
273
+ if (typeof source === "string") {
274
+ if (isUrl(source)) {
275
+ findings.warn("entry-source-unsupported", { path, subject: entryName, detail: source });
276
+ return undefined;
277
+ }
278
+ candidate = source;
279
+ } else if (isJsonObject(source) && source["source"] === "local" && typeof source["path"] === "string") {
280
+ candidate = source["path"];
281
+ } else {
282
+ findings.warn("entry-source-unsupported", { path, subject: entryName, detail: describeSource(source) });
283
+ return undefined;
284
+ }
285
+
286
+ const resolved = relativeDirectory(candidate);
287
+ if (!resolved.ok) {
288
+ findings.error("entry-source-escapes-root", { path, subject: entryName, detail: candidate });
289
+ return undefined;
290
+ }
291
+ return joinDirectories(pluginRoot, resolved.dir);
292
+ }
293
+
294
+ /** The `source` discriminator of a remote object, or the whole value, for the sentence. */
295
+ function describeSource(source: unknown): string {
296
+ if (isJsonObject(source) && typeof source["source"] === "string") return source["source"];
297
+ return describeValue(source);
298
+ }
299
+
300
+ function isUrl(value: string): boolean {
301
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(value);
302
+ }
303
+
304
+ type RelativeDirectory = { readonly ok: true; readonly dir: string } | { readonly ok: false };
305
+
306
+ /**
307
+ * `./thermos`, `thermos`, `third_party/github/`, `.` and `./` all name a
308
+ * directory inside the root (the root itself is the empty string); an
309
+ * absolute path, a backslash, or a `..` segment does not.
310
+ */
311
+ function relativeDirectory(value: string): RelativeDirectory {
312
+ let trimmed = value;
313
+ if (trimmed === "." || trimmed === "./") return { ok: true, dir: "" };
314
+ if (trimmed.startsWith("./")) trimmed = trimmed.slice(2);
315
+ trimmed = trimmed.replace(/\/+$/, "");
316
+ if (trimmed === "") return { ok: true, dir: "" };
317
+ return isContainedPath(trimmed) ? { ok: true, dir: trimmed } : { ok: false };
318
+ }
319
+
320
+ function joinDirectories(root: string, dir: string): string {
321
+ if (root === "") return dir;
322
+ return dir === "" ? root : `${root}/${dir}`;
323
+ }
324
+
325
+ /** The entries the tree can install: a directory that exists and holds a plugin manifest at its root. */
326
+ function offeredEntries(
327
+ raw: readonly RawEntry[],
328
+ index: PluginFileIndex,
329
+ path: string,
330
+ findings: MarketplaceFindings,
331
+ ): readonly MarketplaceEntry[] {
332
+ const offered: MarketplaceEntry[] = [];
333
+ for (const entry of raw) {
334
+ if (entry.dir === undefined) continue;
335
+ if (!index.isDirectory(entry.dir)) {
336
+ findings.warn("entry-directory-missing", { path, subject: entry.name, detail: entry.dir === "" ? "." : entry.dir });
337
+ continue;
338
+ }
339
+ if (!hasPluginManifest(childFilesOf(index, entry.dir))) {
340
+ findings.warn("entry-not-a-plugin", { path, subject: entry.name, detail: entry.dir === "" ? "." : entry.dir });
341
+ continue;
342
+ }
343
+ offered.push({
344
+ name: entry.name,
345
+ dir: entry.dir,
346
+ ...(entry.description !== undefined && { description: entry.description }),
347
+ });
348
+ }
349
+ return offered;
350
+ }
351
+
352
+ /** The files under `dir` as plugin-relative paths, so `hasPluginManifest` reads them as it reads a plugin's own listing. */
353
+ function childFilesOf(index: PluginFileIndex, dir: string): readonly string[] {
354
+ const prefix = dir === "" ? "" : `${dir}/`;
355
+ return index.filesUnder(dir).map((file) => file.slice(prefix.length));
356
+ }
357
+
358
+ /**
359
+ * The defaults, in install order: Stigmer's `defaults` list, or Codex's
360
+ * `INSTALLED_BY_DEFAULT` entries in file order. A default that names no
361
+ * listed entry refuses the file; a default whose entry the tree could not
362
+ * offer is dropped silently, because that entry's own warning already says
363
+ * why it is missing.
364
+ */
365
+ function resolveDefaults(
366
+ object: JsonObject,
367
+ raw: readonly RawEntry[],
368
+ offered: readonly MarketplaceEntry[],
369
+ dialect: MarketplaceDialect,
370
+ path: string,
371
+ findings: MarketplaceFindings,
372
+ ): readonly string[] {
373
+ const listed = new Set(raw.map((entry) => entry.name));
374
+ const offeredNames = new Set(offered.map((entry) => entry.name));
375
+
376
+ let named: readonly string[];
377
+ if (dialect === "stigmer") {
378
+ const value = object["defaults"];
379
+ if (value === undefined) {
380
+ named = [];
381
+ } else if (!isStringArray(value)) {
382
+ findings.error("marketplace-field-type", { path, subject: "defaults", detail: "an array of strings" });
383
+ named = [];
384
+ } else {
385
+ named = value;
386
+ }
387
+ } else if (dialect === "codex") {
388
+ named = raw.filter((entry) => entry.installedByDefault).map((entry) => entry.name);
389
+ } else {
390
+ named = [];
391
+ }
392
+
393
+ const defaults: string[] = [];
394
+ for (const name of named) {
395
+ if (!listed.has(name)) {
396
+ findings.error("default-unknown", { path, subject: name });
397
+ continue;
398
+ }
399
+ if (offeredNames.has(name) && !defaults.includes(name)) defaults.push(name);
400
+ }
401
+ return defaults;
402
+ }
package/src/messages.ts CHANGED
@@ -11,13 +11,7 @@
11
11
  * kind unions, so a kind without a sentence does not compile.
12
12
  */
13
13
 
14
- import type {
15
- FindingContext,
16
- PluginErrorKind,
17
- PluginFinding,
18
- PluginFindingKind,
19
- PluginWarningKind,
20
- } from "./outcome.js";
14
+ import type { Finding, FindingContext, PluginErrorKind, PluginFindingKind, PluginWarningKind } from "./outcome.js";
21
15
 
22
16
  /** The canonical `$schema` identifiers the open format pins per version. */
23
17
  export const AGENT_PLUGINS_MANIFEST_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
@@ -31,10 +25,12 @@ export const MANIFEST_LOCATIONS = {
31
25
  codex: ".codex-plugin/plugin.json",
32
26
  } as const;
33
27
 
34
- const q = (value: string | undefined): string => `'${value ?? ""}'`;
35
- const at = (path: string | undefined): string => (path === undefined ? "" : ` in ${q(path)}`);
28
+ /** Single-quote an identifier for a sentence (the ts-server quoting rule); shared with the marketplace vocabulary. */
29
+ export const q = (value: string | undefined): string => `'${value ?? ""}'`;
30
+ /** ` in '<path>'`, or nothing when the finding names no file. */
31
+ export const at = (path: string | undefined): string => (path === undefined ? "" : ` in ${q(path)}`);
36
32
 
37
- type Sentence = (ctx: FindingContext) => string;
33
+ export type Sentence = (ctx: FindingContext) => string;
38
34
 
39
35
  const ERROR_MESSAGES: Record<PluginErrorKind, Sentence> = {
40
36
  "no-manifest": () =>
@@ -175,27 +171,44 @@ export function isErrorKind(kind: PluginFindingKind): kind is PluginErrorKind {
175
171
  return kind in ERROR_MESSAGES;
176
172
  }
177
173
 
174
+ /** A sentence table over a closed kind vocabulary; `Record` so a kind without a sentence does not compile. */
175
+ export type SentenceTable<K extends string> = Readonly<Record<K, Sentence>>;
176
+
178
177
  /**
179
- * Collects findings while a read proceeds. The reader never throws on a
180
- * plugin's content: every problem becomes a finding here, and the read
181
- * continues so the outcome carries them all.
178
+ * Collects findings while a read proceeds. A reader never throws on the
179
+ * content it reads: every problem becomes a finding here, and the read
180
+ * continues so the outcome carries them all. Generic over the two kind
181
+ * vocabularies so the plugin reader and the marketplace reader share one
182
+ * collector and one finding shape while each owns its sentences.
182
183
  */
183
- export class Findings {
184
- readonly errors: PluginFinding[] = [];
185
- readonly warnings: PluginFinding[] = [];
184
+ export class FindingCollector<E extends string, W extends string> {
185
+ readonly errors: Finding<E>[] = [];
186
+ readonly warnings: Finding<W>[] = [];
187
+
188
+ constructor(
189
+ private readonly errorSentences: SentenceTable<E>,
190
+ private readonly warningSentences: SentenceTable<W>,
191
+ ) {}
186
192
 
187
- error(kind: PluginErrorKind, ctx: FindingContext = {}): void {
188
- this.errors.push(compose(kind, ctx, errorMessage(kind, ctx)));
193
+ error(kind: E, ctx: FindingContext = {}): void {
194
+ this.errors.push(compose(kind, ctx, this.errorSentences[kind](ctx)));
189
195
  }
190
196
 
191
- warn(kind: PluginWarningKind, ctx: FindingContext = {}): void {
192
- this.warnings.push(compose(kind, ctx, warningMessage(kind, ctx)));
197
+ warn(kind: W, ctx: FindingContext = {}): void {
198
+ this.warnings.push(compose(kind, ctx, this.warningSentences[kind](ctx)));
199
+ }
200
+ }
201
+
202
+ /** The plugin reader's collector, bound to the plugin vocabulary. */
203
+ export class Findings extends FindingCollector<PluginErrorKind, PluginWarningKind> {
204
+ constructor() {
205
+ super(ERROR_MESSAGES, WARNING_MESSAGES);
193
206
  }
194
207
  }
195
208
 
196
209
  // Optional fields are omitted rather than set to `undefined` so a finding
197
210
  // serialises to JSON without `null`s and compares structurally in tests.
198
- function compose(kind: PluginFindingKind, ctx: FindingContext, message: string): PluginFinding {
211
+ function compose<K extends string>(kind: K, ctx: FindingContext, message: string): Finding<K> {
199
212
  return {
200
213
  kind,
201
214
  ...(ctx.path !== undefined && { path: ctx.path }),
package/src/outcome.ts CHANGED
@@ -102,20 +102,28 @@ export type PluginWarningKind =
102
102
 
103
103
  export type PluginFindingKind = PluginErrorKind | PluginWarningKind;
104
104
 
105
- export interface PluginFinding {
106
- readonly kind: PluginFindingKind;
105
+ /**
106
+ * One finding over any closed kind vocabulary. The plugin reader and the
107
+ * marketplace reader (`marketplace/`) each own a vocabulary and a sentence
108
+ * table; the shape of a finding, and the renderer that prints one, are
109
+ * shared through this type.
110
+ */
111
+ export interface Finding<K extends string> {
112
+ readonly kind: K;
107
113
  /** The file, or the declared path, the finding is about. */
108
114
  readonly path?: string;
109
- /** The skill, server, sub-agent, variable or field the finding names. */
115
+ /** The skill, server, sub-agent, variable, entry or field the finding names. */
110
116
  readonly subject?: string;
111
117
  /** A second identifier the sentence needs (a field name, a parser's own message). */
112
118
  readonly detail?: string;
113
- /** The one sentence for this kind, from `messages.ts`. */
119
+ /** The one sentence for this kind, from the vocabulary's `messages.ts`. */
114
120
  readonly message: string;
115
121
  }
116
122
 
117
- /** What `read` needs to compose a finding: everything but the sentence. */
118
- export type FindingContext = Pick<PluginFinding, "path" | "subject" | "detail">;
123
+ export type PluginFinding = Finding<PluginFindingKind>;
124
+
125
+ /** What a reader needs to compose a finding: everything but the sentence. */
126
+ export type FindingContext = Pick<Finding<string>, "path" | "subject" | "detail">;
119
127
 
120
128
  export type PluginReadOutcome =
121
129
  | { readonly ok: true; readonly plugin: PluginPackage; readonly warnings: readonly PluginFinding[] }