@hajimism/dek 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +10 -3
  3. package/src/cli/build.ts +3 -1
  4. package/src/cli/check.ts +20 -11
  5. package/src/cli/commands.ts +24 -10
  6. package/src/cli/contract.ts +28 -2
  7. package/src/cli/flags.ts +5 -0
  8. package/src/cli/lint.ts +53 -1
  9. package/src/cli/sync.ts +21 -6
  10. package/src/cli/text.ts +20 -2
  11. package/src/core/build.ts +4 -0
  12. package/src/core/config-keys.ts +7 -2
  13. package/src/core/config.ts +63 -0
  14. package/src/core/diagnostic.ts +9 -1
  15. package/src/core/document.ts +28 -3
  16. package/src/core/lint/script.ts +2 -4
  17. package/src/core/page.ts +1 -0
  18. package/src/core/plugin-host.ts +709 -0
  19. package/src/core/plugin.ts +442 -0
  20. package/src/core/sarif.ts +47 -5
  21. package/src/core/script-lines.ts +11 -0
  22. package/src/core/sync.ts +4 -2
  23. package/src/plugin-api.ts +126 -0
  24. package/src/plugins/study/check.ts +128 -0
  25. package/src/plugins/study/main.ts +47 -0
  26. package/src/plugins/study/manifest.ts +32 -0
  27. package/src/plugins/study/page.ts +295 -0
  28. package/src/plugins/study/place.ts +88 -0
  29. package/src/plugins/study/sarif.ts +30 -0
  30. package/src/plugins/study/study.ts +137 -0
  31. package/src/plugins/study/text.ts +55 -0
  32. package/src/plugins/study/yaml.ts +226 -0
  33. package/src/plugins/words/check.ts +109 -0
  34. package/src/plugins/words/lines.ts +26 -0
  35. package/src/plugins/words/list.ts +288 -0
  36. package/src/plugins/words/main.ts +50 -0
  37. package/src/plugins/words/manifest.ts +36 -0
  38. package/src/plugins/words/match.ts +60 -0
  39. package/src/plugins/words/sarif.ts +37 -0
  40. package/src/plugins/words/text.ts +352 -0
  41. package/src/plugins/words/yaml.ts +225 -0
  42. package/src/runtime/player.ts +18 -2
  43. package/src/runtime/plugins.ts +236 -0
  44. package/src/runtime/start.ts +16 -1
  45. package/src/runtime/window.ts +3 -0
  46. package/src/server/deck-snapshot.ts +32 -1
  47. package/src/server/dev.ts +7 -1
  48. package/src/server/pages.ts +9 -1
@@ -0,0 +1,442 @@
1
+ import { z } from "zod";
2
+ import { formatZodIssues } from "./zod.ts";
3
+
4
+ /**
5
+ * A plugin is an executable `dek-<name>` the user installed. It prints a manifest of what it promises,
6
+ * which dek checks and the project pins in `plugins/<name>/plugin.json`, and it checks a deck in a
7
+ * process of its own, answering in SARIF. This file is the part with no I/O: what a manifest may
8
+ * say, the lock it is pinned in, and the words dek writes about it. See ADR 32.
9
+ */
10
+
11
+ /** The manifest schema and check protocol this dek reads; a plugin that needs a newer one is skipped. */
12
+ const MANIFEST_SCHEMA = 1;
13
+ export const CHECK_PROTOCOL = 1;
14
+ /** The page API this dek gives a plugin's page (ADR 33). */
15
+ const PAGE_API = 1;
16
+
17
+ /** The letters the player binds, which no plugin's page may take; a test reads them from the runtime. */
18
+ export const DEK_KEYS: readonly string[] = ["a", "f", "l", "m", "p", "s"];
19
+
20
+ const NAME_RE = /^[a-z][a-z0-9-]*$/;
21
+ const NAME_MAX = 40;
22
+
23
+ /** Names whose rule ids would read as another tool's: dek's own, and rumdl's MD rules. */
24
+ const RESERVED_NAMES: Record<string, string> = {
25
+ dek: "DEK is the prefix of dek's own rules",
26
+ md: "MD is the prefix of rumdl's rules",
27
+ };
28
+
29
+ /** What is wrong with `name` as a plugin's, or undefined when it is one. */
30
+ export function pluginNameProblem(name: string): string | undefined {
31
+ if (!NAME_RE.test(name) || name.length > NAME_MAX) {
32
+ return `a plugin's name is a lowercase letter, then lowercase letters, digits, and hyphens, at most ${NAME_MAX}`;
33
+ }
34
+ const reserved = RESERVED_NAMES[name];
35
+ return reserved === undefined ? undefined : `\`${name}\` is not a plugin's name: ${reserved}`;
36
+ }
37
+
38
+ /** The prefix of a plugin's rule ids: `acme-talks` checks ACME_TALKS001 and on. */
39
+ export function rulePrefix(name: string): string {
40
+ return name.toUpperCase().replaceAll("-", "_");
41
+ }
42
+
43
+ /** A rule's summary: one line of fact, which dek shows only where a tool lists rules, as SARIF. */
44
+ const SUMMARY_MAX = 120;
45
+ const VERSION_RE = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/;
46
+
47
+ /** The first folders of a deck that are dek's, and the files at its top, in lowercase. */
48
+ const DEK_DECK_ENTRIES = new Set(["script.md", "theme.css", "slides", "assets", "voice", "dist"]);
49
+
50
+ const Rule = z.object({
51
+ severity: z.enum(["error", "warning"]),
52
+ summary: z
53
+ .string()
54
+ .min(1)
55
+ .max(SUMMARY_MAX)
56
+ // Control characters, and the line and paragraph separators, would end the line.
57
+ .refine((text) => !/[\p{Cc}\p{Zl}\p{Zp}]/u.test(text), "summary: one line"),
58
+ });
59
+
60
+ const Check = z.object({
61
+ protocol: z.literal(CHECK_PROTOCOL),
62
+ rules: z.record(z.string(), Rule),
63
+ });
64
+
65
+ /**
66
+ * A data file: a path in the deck, or, with `in: "project"`, in the plugin's own `plugins/<name>/`
67
+ * in the project (ADR 35).
68
+ */
69
+ const Data = z.object({ path: z.string(), in: z.literal("project").optional() });
70
+
71
+ /** A page in the player and the build. Its script is the plugin's to ship; this dek reads only these. */
72
+ const Page = z.object({
73
+ api: z.literal(PAGE_API),
74
+ keys: z.array(z.string()).optional(),
75
+ });
76
+
77
+ /** A manifest as this dek reads it. Fields it does not know are dropped, never passed on. */
78
+ const Manifest = z.object({
79
+ schema: z.literal(MANIFEST_SCHEMA),
80
+ name: z.string(),
81
+ version: z.string().regex(VERSION_RE, "version: one token of letters, digits, and . + -"),
82
+ check: Check.optional(),
83
+ page: Page.optional(),
84
+ data: z.array(Data).optional(),
85
+ });
86
+
87
+ export type PluginManifest = z.output<typeof Manifest>;
88
+ type PluginRule = z.output<typeof Rule>;
89
+
90
+ /** A manifest dek can use, or why not; `unsupported` when it asks for a newer dek. */
91
+ export type ManifestReading =
92
+ | { ok: true; manifest: PluginManifest }
93
+ | { ok: false; reason: string; unsupported?: true };
94
+
95
+ /**
96
+ * What `dek-<name> manifest` printed, checked: its shape and versions, that it names the plugin
97
+ * dek ran, that every rule id has the plugin's prefix, and that each data file is the deck's own
98
+ * and not dek's. Only what this dek knows is kept, so the lock pins what it can act on.
99
+ */
100
+ export function readManifest(value: unknown, name: string): ManifestReading {
101
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
102
+ return { ok: false, reason: "the manifest is not a JSON object" };
103
+ }
104
+ const fields = value as Record<string, unknown>;
105
+ if (above(fields.schema, MANIFEST_SCHEMA)) {
106
+ return newer(`manifest schema ${fields.schema}`);
107
+ }
108
+ const check = fields.check as Record<string, unknown> | undefined;
109
+ if (typeof check === "object" && check !== null && above(check.protocol, CHECK_PROTOCOL)) {
110
+ return newer(`check protocol ${check.protocol}`);
111
+ }
112
+ const page = fields.page as Record<string, unknown> | undefined;
113
+ if (typeof page === "object" && page !== null && above(page.api, PAGE_API)) {
114
+ return newer(`page API ${page.api}`);
115
+ }
116
+ const parsed = Manifest.safeParse(value);
117
+ if (!parsed.success) {
118
+ return { ok: false, reason: formatZodIssues(parsed.error) };
119
+ }
120
+ const manifest = parsed.data;
121
+ const problem =
122
+ (manifest.name === name
123
+ ? undefined
124
+ : `it names itself \`${manifest.name}\`, not \`${name}\``) ??
125
+ ruleProblem(manifest, name) ??
126
+ keyProblem(manifest) ??
127
+ dataProblem(manifest);
128
+ return problem === undefined
129
+ ? { ok: true, manifest: canonical(manifest) }
130
+ : { ok: false, reason: problem };
131
+ }
132
+
133
+ /** A version a later dek may read: a whole number past this dek's. Any other is malformed. */
134
+ function above(version: unknown, supported: number): version is number {
135
+ return Number.isInteger(version) && (version as number) > supported;
136
+ }
137
+
138
+ function newer(what: string): ManifestReading {
139
+ return {
140
+ ok: false,
141
+ unsupported: true,
142
+ reason: `it needs ${what}; this dek reads manifest schema ${MANIFEST_SCHEMA}, check protocol ${CHECK_PROTOCOL}, and page API ${PAGE_API}`,
143
+ };
144
+ }
145
+
146
+ /** A page's keys are single lowercase letters, each once, none of them the player's. */
147
+ function keyProblem(manifest: PluginManifest): string | undefined {
148
+ const keys = manifest.page?.keys ?? [];
149
+ const wrong = keys.find((key) => !/^[a-z]$/.test(key));
150
+ if (wrong !== undefined) {
151
+ return "page.keys: a key is one lowercase letter, a to z";
152
+ }
153
+ const taken = keys.find((key) => DEK_KEYS.includes(key));
154
+ if (taken !== undefined) {
155
+ return `page.keys: \`${taken}\` is a key of dek's player; take a letter other than ${DEK_KEYS.join(" ")}`;
156
+ }
157
+ const twice = keys.find((key, index) => keys.indexOf(key) !== index);
158
+ return twice === undefined ? undefined : `page.keys: \`${twice}\` is named twice`;
159
+ }
160
+
161
+ function ruleProblem(manifest: PluginManifest, name: string): string | undefined {
162
+ const prefix = rulePrefix(name);
163
+ const id = new RegExp(`^${prefix}\\d{3}$`);
164
+ const wrong = Object.keys(manifest.check?.rules ?? {}).find((rule) => !id.test(rule));
165
+ return wrong === undefined
166
+ ? undefined
167
+ : `check.rules: \`${wrong}\` is no rule id of this plugin's; write ${prefix} and three digits, like ${prefix}001`;
168
+ }
169
+
170
+ function dataProblem(manifest: PluginManifest): string | undefined {
171
+ const seen = new Set<string>();
172
+ for (const entry of manifest.data ?? []) {
173
+ const shown = dataPath(manifest.name, entry);
174
+ const problem = dataPathProblem(entry);
175
+ if (problem) {
176
+ return `data: \`${entry.path}\` ${problem}`;
177
+ }
178
+ if (seen.has(shown)) {
179
+ return `data: \`${shown}\` is named twice`;
180
+ }
181
+ seen.add(shown);
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ type DataEntry = NonNullable<PluginManifest["data"]>[number];
187
+
188
+ /** A data file as an agent finds it: in the deck, or in the project when it is the project's. */
189
+ function dataPath(name: string, entry: DataEntry): string {
190
+ return entry.in === "project" ? `plugins/${name}/${entry.path}` : entry.path;
191
+ }
192
+
193
+ /**
194
+ * A data file is a path inside its folder, `/`-separated, through no hidden folder. In a deck it
195
+ * is none of the files and folders dek reads or writes there; in `plugins/<name>/` it is not the
196
+ * lock.
197
+ */
198
+ function dataPathProblem({ path, in: level }: DataEntry): string | undefined {
199
+ const folder = level === "project" ? "plugins/<name>/" : "the deck";
200
+ if (path === "" || path.startsWith("/") || path.includes("\\")) {
201
+ return `is not a path relative to ${folder}`;
202
+ }
203
+ const segments = path.split("/");
204
+ if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
205
+ return `is not a path inside ${folder}`;
206
+ }
207
+ if (segments.some((segment) => segment.startsWith("."))) {
208
+ return "is hidden, and dek reads no hidden file of a plugin's";
209
+ }
210
+ // A Mac's disk and Windows' take `Slides` for `slides`.
211
+ const first = (segments[0] ?? "").normalize("NFC").toLowerCase();
212
+ if (level === "project") {
213
+ return segments.length === 1 && first === "plugin.json" ? "is the plugin's lock" : undefined;
214
+ }
215
+ if (DEK_DECK_ENTRIES.has(first)) {
216
+ return "is dek's";
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ /** The manifest with its keys in one order, so the same promise is always the same lock. */
222
+ function canonical(manifest: PluginManifest): PluginManifest {
223
+ const rules = manifest.check?.rules;
224
+ return {
225
+ schema: manifest.schema,
226
+ name: manifest.name,
227
+ version: manifest.version,
228
+ ...(manifest.check && rules
229
+ ? {
230
+ check: {
231
+ protocol: manifest.check.protocol,
232
+ rules: Object.fromEntries(
233
+ Object.keys(rules)
234
+ .sort()
235
+ .map((id) => {
236
+ const rule = rules[id] as PluginRule;
237
+ return [id, { severity: rule.severity, summary: rule.summary }];
238
+ }),
239
+ ),
240
+ },
241
+ }
242
+ : {}),
243
+ ...(manifest.page
244
+ ? { page: { api: manifest.page.api, keys: [...(manifest.page.keys ?? [])].sort() } }
245
+ : {}),
246
+ ...(manifest.data
247
+ ? {
248
+ data: manifest.data.map((entry) =>
249
+ entry.in === "project" ? { path: entry.path, in: entry.in } : { path: entry.path },
250
+ ),
251
+ }
252
+ : {}),
253
+ };
254
+ }
255
+
256
+ /** `plugins/<name>/plugin.json`: the manifest the project accepted, which is its lock. */
257
+ export function lockText(manifest: PluginManifest): string {
258
+ return `${JSON.stringify({ accepted: canonical(manifest) }, null, 2)}\n`;
259
+ }
260
+
261
+ /** The manifest a lock pins, read as `manifest`'s output is, so a newer dek's fields are left out. */
262
+ export function readLock(text: string, name: string): ManifestReading {
263
+ let parsed: unknown;
264
+ try {
265
+ parsed = JSON.parse(text);
266
+ } catch {
267
+ return { ok: false, reason: "it is not JSON" };
268
+ }
269
+ const accepted =
270
+ typeof parsed === "object" && parsed !== null
271
+ ? (parsed as { accepted?: unknown }).accepted
272
+ : undefined;
273
+ if (accepted === undefined) {
274
+ return { ok: false, reason: "it has no `accepted` manifest" };
275
+ }
276
+ return readManifest(accepted, name);
277
+ }
278
+
279
+ /**
280
+ * What changed from the manifest the project accepted to the one installed, one line per change
281
+ * in dek's words: the version, each rule added, removed, or changed, and each data file.
282
+ */
283
+ export function manifestChanges(accepted: PluginManifest, installed: PluginManifest): string[] {
284
+ const changes: string[] = [];
285
+ if (accepted.version !== installed.version) {
286
+ changes.push(`version ${accepted.version} → ${installed.version}`);
287
+ }
288
+ changes.push(...checkChanges(accepted.check, installed.check));
289
+ changes.push(...pageChanges(accepted.page, installed.page));
290
+ const paths = (manifest: PluginManifest) =>
291
+ new Set((manifest.data ?? []).map((entry) => dataPath(manifest.name, entry)));
292
+ const before = paths(accepted);
293
+ const after = paths(installed);
294
+ for (const path of before) {
295
+ if (!after.has(path)) {
296
+ changes.push(`data ${path} removed`);
297
+ }
298
+ }
299
+ for (const path of after) {
300
+ if (!before.has(path)) {
301
+ changes.push(`data ${path} added`);
302
+ }
303
+ }
304
+ return changes;
305
+ }
306
+
307
+ function pageChanges(
308
+ accepted: PluginManifest["page"],
309
+ installed: PluginManifest["page"],
310
+ ): string[] {
311
+ const keys = (page: PluginManifest["page"]) => (page?.keys ?? []).join(", ") || "none";
312
+ if (!accepted && !installed) {
313
+ return [];
314
+ }
315
+ if (!installed) {
316
+ return ["page removed"];
317
+ }
318
+ if (!accepted) {
319
+ return [`page added (keys ${keys(installed)})`];
320
+ }
321
+ return keys(accepted) === keys(installed)
322
+ ? []
323
+ : [`page keys ${keys(accepted)} → ${keys(installed)}`];
324
+ }
325
+
326
+ function checkChanges(
327
+ accepted: PluginManifest["check"],
328
+ installed: PluginManifest["check"],
329
+ ): string[] {
330
+ if (!accepted && !installed) {
331
+ return [];
332
+ }
333
+ if (!installed) {
334
+ return ["check removed"];
335
+ }
336
+ if (!accepted) {
337
+ const rules = Object.entries(installed.rules)
338
+ .sort(([a], [b]) => a.localeCompare(b))
339
+ .map(([id, rule]) => `${id} (${rule.severity})`);
340
+ return [`check added: ${rules.join(", ")}`];
341
+ }
342
+ const changes: string[] = [];
343
+ const ids = [
344
+ ...new Set([...Object.keys(accepted.rules), ...Object.keys(installed.rules)]),
345
+ ].sort();
346
+ for (const id of ids) {
347
+ const before = accepted.rules[id];
348
+ const after = installed.rules[id];
349
+ if (!after) {
350
+ changes.push(`${id} removed`);
351
+ } else if (!before) {
352
+ changes.push(`${id} added (${after.severity})`);
353
+ } else if (before.severity !== after.severity) {
354
+ changes.push(`${id} ${before.severity} → ${after.severity}`);
355
+ } else if (before.summary !== after.summary) {
356
+ changes.push(`${id} summary changed`);
357
+ }
358
+ }
359
+ return changes;
360
+ }
361
+
362
+ /**
363
+ * A plugin dek.toml names, with the manifest its lock pins when it has one, and dek's own page on
364
+ * how to write its data when dek ships it.
365
+ */
366
+ export type PinnedPlugin = { name: string; manifest: PluginManifest | undefined; guide?: string };
367
+
368
+ /**
369
+ * The plugins in AGENTS.md: one line each, made from the lock and never from the executable, so
370
+ * every sync writes the same block, the dev server's included. Each line is dek's words about
371
+ * checked fields; the plugin's own summaries stay out. A data file says whether it is the
372
+ * project's or each deck's, since the two can share a name. Empty when dek.toml names no plugin.
373
+ */
374
+ export function pluginsSection(plugins: PinnedPlugin[]): string {
375
+ if (plugins.length === 0) {
376
+ return "";
377
+ }
378
+ const lines = plugins.map(({ name, manifest, guide }) => {
379
+ if (!manifest) {
380
+ return `- \`${name}\`: not pinned yet; \`dekc sync\` pins it.`;
381
+ }
382
+ const facts = [
383
+ ...(manifest.check ? [`It checks ${ruleList(Object.keys(manifest.check.rules))}.`] : []),
384
+ ...(manifest.data && manifest.data.length > 0
385
+ ? [
386
+ `Its data: ${projectFirst(manifest.data)
387
+ .map(
388
+ (entry) =>
389
+ `\`${dataPath(name, entry)}\` ${entry.in === "project" ? "in the project" : "in each deck"}`,
390
+ )
391
+ .join(", ")}.`,
392
+ ]
393
+ : []),
394
+ ...(manifest.page?.keys && manifest.page.keys.length > 0
395
+ ? [
396
+ `Its page takes the ${manifest.page.keys.length === 1 ? "key" : "keys"} ${manifest.page.keys.map((key) => `\`${key}\``).join(", ")}.`,
397
+ ]
398
+ : []),
399
+ ...(guide && manifest.data && manifest.data.length > 0 ? [`How to write it: ${guide}`] : []),
400
+ ];
401
+ return `- \`${name}\` ${manifest.version}${facts.length > 0 ? `: ${facts.join(" ")}` : ""}`;
402
+ });
403
+ return `
404
+ ## Plugins
405
+
406
+ Checks installed beside dek, named in \`dek.toml\` \`[plugins]\` and pinned in \`plugins/<name>/plugin.json\`. \`dekc lint\` runs them with dek's own rules, and \`dekc check <slug>\` reports what they find on that slide; their findings carry the plugin's rule ids.
407
+
408
+ ${lines.join("\n")}
409
+ `;
410
+ }
411
+
412
+ /** The project's data files, then the deck's, each in the order the manifest gave them. */
413
+ function projectFirst(data: DataEntry[]): DataEntry[] {
414
+ return [...data.filter((entry) => entry.in === "project"), ...data.filter((e) => !e.in)];
415
+ }
416
+
417
+ function ruleList(ids: string[]): string {
418
+ const sorted = [...ids].sort();
419
+ if (sorted.length === 0) {
420
+ return "no rules";
421
+ }
422
+ if (sorted.length === 1) {
423
+ return sorted[0] as string;
424
+ }
425
+ if (sorted.length === 2) {
426
+ return `${sorted[0]} and ${sorted[1]}`;
427
+ }
428
+ const numbers = sorted.map((id) => Number(id.slice(-3)));
429
+ const contiguous = numbers.every((n, i) => i === 0 || n === (numbers[i - 1] as number) + 1);
430
+ return contiguous ? `${sorted[0]} to ${sorted.at(-1)}` : sorted.join(", ");
431
+ }
432
+
433
+ /**
434
+ * A plugin's page as the player and the build carry it: its script, the keys its manifest
435
+ * declares, and each declared data file the deck has, with a version that changes with the text.
436
+ */
437
+ export type PluginPage = {
438
+ name: string;
439
+ keys: string[];
440
+ script: string;
441
+ data: Record<string, { text: string; version: string }>;
442
+ };
package/src/core/sarif.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import { resolve } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
- import { type Diagnostic, type DiagnosticValue, RULES, type SkippedCheck } from "./diagnostic.ts";
3
+ import {
4
+ type Diagnostic,
5
+ type DiagnosticValue,
6
+ RULES,
7
+ type SkippedCheck,
8
+ skippedLabel,
9
+ } from "./diagnostic.ts";
10
+ import type { PluginManifest } from "./plugin.ts";
4
11
 
5
12
  type SarifLevel = "error" | "warning" | "note" | "none";
6
13
 
@@ -11,8 +18,14 @@ export type SarifLog = {
11
18
  tool: {
12
19
  driver: {
13
20
  name: string;
21
+ version?: string;
14
22
  informationUri?: string;
15
- rules?: Array<{ id: string; defaultConfiguration: { level: SarifLevel }; helpUri: string }>;
23
+ rules?: Array<{
24
+ id: string;
25
+ shortDescription?: { text: string };
26
+ defaultConfiguration: { level: SarifLevel };
27
+ helpUri?: string;
28
+ }>;
16
29
  };
17
30
  };
18
31
  invocations?: Array<{
@@ -74,10 +87,11 @@ export function toSarif(
74
87
  executionSuccessful: true,
75
88
  toolExecutionNotifications: skipped.map((entry) => ({
76
89
  level: "warning" as const,
77
- message: { text: `${entry.check}: skipped (${entry.reason})` },
90
+ message: { text: `${skippedLabel(entry)}: skipped (${entry.reason})` },
78
91
  descriptor: { id: `skipped/${entry.check}` },
79
92
  properties: {
80
93
  check: entry.check,
94
+ ...(entry.plugin === undefined ? {} : { plugin: entry.plugin }),
81
95
  ...(entry.hint === undefined ? {} : { hint: entry.hint }),
82
96
  },
83
97
  })),
@@ -91,6 +105,33 @@ export function toSarif(
91
105
  };
92
106
  }
93
107
 
108
+ /**
109
+ * Each plugin's findings as a run of its own, under the plugin's name and version, with the rules
110
+ * its accepted manifest declares. A plugin that found nothing still has its run: it ran.
111
+ */
112
+ export function pluginSarif(
113
+ plugins: Array<{ name: string; manifest: PluginManifest; diagnostics: Diagnostic[] }>,
114
+ ): SarifLog {
115
+ return {
116
+ version: "2.1.0",
117
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
118
+ runs: plugins.map(({ name, manifest, diagnostics }) => ({
119
+ tool: {
120
+ driver: {
121
+ name: `dek-${name}`,
122
+ version: manifest.version,
123
+ rules: Object.entries(manifest.check?.rules ?? {}).map(([id, rule]) => ({
124
+ id,
125
+ shortDescription: { text: rule.summary },
126
+ defaultConfiguration: { level: rule.severity },
127
+ })),
128
+ },
129
+ },
130
+ results: diagnostics.map(sarifResult),
131
+ })),
132
+ };
133
+ }
134
+
94
135
  function sarifResult(diagnostic: Diagnostic): SarifLog["runs"][number]["results"][number] {
95
136
  const properties = {
96
137
  ...(diagnostic.slug === undefined ? {} : { slug: diagnostic.slug }),
@@ -126,10 +167,11 @@ function sarifResult(diagnostic: Diagnostic): SarifLog["runs"][number]["results"
126
167
  };
127
168
  }
128
169
 
129
- export function mergeSarif(dek: SarifLog, rumdl?: SarifLog): SarifLog {
170
+ /** One log of every run in `dek` and the logs after it, such as rumdl's and the plugins'. */
171
+ export function mergeSarif(dek: SarifLog, ...others: Array<SarifLog | undefined>): SarifLog {
130
172
  return {
131
173
  version: "2.1.0",
132
174
  $schema: dek.$schema,
133
- runs: [...dek.runs, ...(rumdl?.runs ?? [])],
175
+ runs: [...dek.runs, ...others.flatMap((log) => log?.runs ?? [])],
134
176
  };
135
177
  }
@@ -51,3 +51,14 @@ function commentOpenAfter(text: string, open: boolean): boolean {
51
51
  }
52
52
  return inside;
53
53
  }
54
+
55
+ /**
56
+ * The slide a line of script.md belongs to: the `##` section it is in, heading included. Above the
57
+ * first heading it belongs to the deck, and to no slide.
58
+ */
59
+ export function slugAtLine(
60
+ sections: ReadonlyArray<{ line: number; slug: string }>,
61
+ line: number,
62
+ ): string | undefined {
63
+ return sections.filter((section) => section.line <= line).at(-1)?.slug;
64
+ }
package/src/core/sync.ts CHANGED
@@ -3,6 +3,8 @@ import { join } from "node:path";
3
3
  import { loadConfig } from "./config.ts";
4
4
  import { type DeckPaths, deckPaths } from "./deck-paths.ts";
5
5
  import { foreignLine, TITLE_MAX } from "./foreign-text.ts";
6
+ import { pluginsSection } from "./plugin.ts";
7
+ import { pinnedPlugins } from "./plugin-host.ts";
6
8
  import { parseRefSource, refDir, refTitle } from "./ref.ts";
7
9
  import { asResolvedDeck, listSlides, type ResolvedDeck, SLIDE_SIDECARS } from "./resolve.ts";
8
10
  import { outputDir, readSourceIfExists, removeInside, writeInside } from "./safe-fs.ts";
@@ -286,10 +288,10 @@ A build system for talks. Write what you will say; dek builds, measures, and shi
286
288
 
287
289
  ## Before you report a deck as done
288
290
 
289
- - \`dekc lint --visual\` passes.
291
+ - \`dekc lint --visual\` passes. Warnings do not fail it: fix each one, or tell the author which you kept and why.
290
292
  - You have read \`dekc shot --sheet\` and judged the deck's balance.
291
293
  - Say what you could not judge, such as the argument and the timing, and leave it to the author.
292
- ${referencesSection(root)}
294
+ ${referencesSection(root)}${pluginsSection(pinnedPlugins(root))}
293
295
  For commands, run \`dekc help --agent\`.
294
296
  `;
295
297
  }
@@ -0,0 +1,126 @@
1
+ /// <reference lib="dom" />
2
+
3
+ /**
4
+ * The types a dek plugin is written against, published as `@hajimism/dek/plugin` (ADR 32). They
5
+ * are types only: a plugin runs in a process of its own and shares no code with dek, only these
6
+ * shapes, which travel as JSON. Each grows only by adding; a change to one is a new `schema` or
7
+ * `protocol`.
8
+ */
9
+
10
+ export type Severity = "error" | "warning";
11
+
12
+ /** One rule a check can report. Only `error` fails `dekc lint`. */
13
+ export type PluginRule = {
14
+ severity: Severity;
15
+ /** One line of fact, at most 120 characters; dek shows it only in SARIF's rule list. */
16
+ summary: string;
17
+ };
18
+
19
+ /** A plugin's rules by id: `<PREFIX>` and three digits, the prefix the name in capitals with `_` for `-`. */
20
+ export type PluginRules<Prefix extends string = string> = Record<`${Prefix}${number}`, PluginRule>;
21
+
22
+ /** What `dek-<name> manifest` prints: the promise the project pins in `plugins/<name>/plugin.json`. */
23
+ export type PluginManifest<Prefix extends string = string> = {
24
+ schema: 1;
25
+ /** The `<name>` of `dek-<name>`. */
26
+ name: string;
27
+ /** Letters, digits, `.`, `+`, and `-`, at most 64 characters. */
28
+ version: string;
29
+ /** Without it, dek runs no check. */
30
+ check?: { protocol: 1; rules: PluginRules<Prefix> };
31
+ /** A page in the player and the build, which calls `dek.plugin` (page API 1). */
32
+ page?: {
33
+ api: 1;
34
+ /** Single letters the page binds; none of dek's own (a f l m p s). */
35
+ keys?: string[];
36
+ };
37
+ /**
38
+ * Files the plugin reads, which AGENTS.md names: relative to the deck's folder, or, with
39
+ * `in: "project"`, to `plugins/<name>/` in the project, for data the whole project shares. Only
40
+ * the deck's are served to a page.
41
+ */
42
+ data?: Array<{ path: string; in?: "project" }>;
43
+ };
44
+
45
+ /** One slide of the deck, as `check` reads it. Each path is relative to the deck's folder, or null when the file is not there. */
46
+ export type CheckSlide = {
47
+ slug: string;
48
+ title: string;
49
+ html: string | null;
50
+ css: string | null;
51
+ ts: string | null;
52
+ };
53
+
54
+ /** What `dek-<name> check --protocol 1` reads on stdin, run in the deck's folder. */
55
+ export type CheckInput = {
56
+ protocol: 1;
57
+ /** The version of the dek that runs the check. */
58
+ dek: string;
59
+ deck: { name: string; lang: string; slides: CheckSlide[] };
60
+ /**
61
+ * The absolute path of `plugins/<name>/` in the project, where the project's data files are,
62
+ * whether or not the folder exists (ADR 35). An older dek does not send it: read that as no
63
+ * project data.
64
+ */
65
+ pluginDir?: string;
66
+ };
67
+
68
+ /** One finding in a check's SARIF: the fields dek reads, and no others. */
69
+ export type CheckResult<RuleId extends string = string> = {
70
+ /** A rule the manifest declares; its severity is the manifest's, whatever `level` says. */
71
+ ruleId: RuleId;
72
+ message: { text: string };
73
+ locations?: Array<{
74
+ physicalLocation: {
75
+ /**
76
+ * Relative to the deck's folder, or a `file:` URL inside it or inside `pluginDir`. dek drops
77
+ * a location anywhere else.
78
+ */
79
+ artifactLocation: { uri: string };
80
+ region?: { startLine: number; startColumn?: number };
81
+ };
82
+ }>;
83
+ /** What to do next. */
84
+ properties?: { hint?: string };
85
+ };
86
+
87
+ /** What `dek-<name> check` prints on stdout: SARIF 2.1.0, of which dek reads each run's results. */
88
+ export type CheckOutput<RuleId extends string = string> = {
89
+ version: "2.1.0";
90
+ $schema?: string;
91
+ runs: Array<{
92
+ tool?: { driver: { name: string; version?: string } };
93
+ results: Array<CheckResult<RuleId>>;
94
+ }>;
95
+ };
96
+
97
+ /** Where the deck stands: the slide, its index, and the beat (0 is the slide's arrival). */
98
+ export type PagePosition = { slug: string; index: number; beat: number };
99
+
100
+ /**
101
+ * A slide drawn on the stage, for a page to draw over. `root` is the slide, to read and never to
102
+ * change: lint checked what is in it. `layer` lies over the slide, in the slide's own pixels, and
103
+ * is the plugin's alone. A slide a live reload replaced is mounted again.
104
+ */
105
+ export type PageMount = { slug: string; root: HTMLElement; layer: ShadowRoot; view: "stage" };
106
+
107
+ /** What `dek.plugin` hands a plugin's page: page API 1. */
108
+ export type PageApi = {
109
+ deck: { name: string };
110
+ slides: Array<{ slug: string; title: string }>;
111
+ current(): PagePosition;
112
+ /** After the deck settles at a position, at the start and after every move, wherever it came from. */
113
+ on(event: "change", listener: (position: PagePosition) => void): void;
114
+ on(event: "mount", listener: (mount: PageMount) => void): void;
115
+ /** Whether `element` shows at the current beat: no beat that hides it is still to come. */
116
+ shown(element: Element): boolean;
117
+ /** A data file the manifest declares, as the project has it; `version` changes with the text. */
118
+ data(path: string): { text: string; version: string } | null;
119
+ /** Binds a key the manifest declares; false for any other. */
120
+ key(letter: string, listener: () => void): boolean;
121
+ };
122
+
123
+ /** The one global a plugin's page touches. */
124
+ export type DekPage = {
125
+ plugin(name: string, options: { api: 1 }, setup: (api: PageApi) => void): void;
126
+ };