@hajimism/dek 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/package.json +12 -5
  3. package/src/cli/address.ts +48 -11
  4. package/src/cli/build.ts +3 -1
  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/keep-alive.ts +12 -6
  9. package/src/cli/lint.ts +53 -1
  10. package/src/cli/main.ts +2 -2
  11. package/src/cli/sync.ts +21 -6
  12. package/src/cli/text.ts +20 -2
  13. package/src/cli/theme.ts +1 -1
  14. package/src/core/build.ts +4 -0
  15. package/src/core/config-keys.ts +7 -2
  16. package/src/core/config.ts +63 -0
  17. package/src/core/diagnostic.ts +9 -1
  18. package/src/core/document.ts +28 -3
  19. package/src/core/lint/context.ts +2 -0
  20. package/src/core/lint/shared-styles.ts +26 -1
  21. package/src/core/lint/theme-css.ts +64 -3
  22. package/src/core/lint.ts +15 -2
  23. package/src/core/page.ts +1 -0
  24. package/src/core/plugin-host.ts +669 -0
  25. package/src/core/plugin.ts +436 -0
  26. package/src/core/sandbox.ts +1 -1
  27. package/src/core/sarif.ts +47 -5
  28. package/src/core/slide-measure.ts +1 -0
  29. package/src/core/spawn.ts +9 -7
  30. package/src/core/sync.ts +3 -1
  31. package/src/plugin-api.ts +126 -0
  32. package/src/plugins/study/check.ts +128 -0
  33. package/src/plugins/study/main.ts +47 -0
  34. package/src/plugins/study/manifest.ts +32 -0
  35. package/src/plugins/study/page.ts +295 -0
  36. package/src/plugins/study/place.ts +88 -0
  37. package/src/plugins/study/sarif.ts +30 -0
  38. package/src/plugins/study/study.ts +137 -0
  39. package/src/plugins/study/text.ts +55 -0
  40. package/src/plugins/study/yaml.ts +226 -0
  41. package/src/plugins/words/check.ts +109 -0
  42. package/src/plugins/words/lines.ts +26 -0
  43. package/src/plugins/words/list.ts +288 -0
  44. package/src/plugins/words/main.ts +50 -0
  45. package/src/plugins/words/manifest.ts +36 -0
  46. package/src/plugins/words/match.ts +60 -0
  47. package/src/plugins/words/sarif.ts +37 -0
  48. package/src/plugins/words/text.ts +352 -0
  49. package/src/plugins/words/yaml.ts +225 -0
  50. package/src/runtime/player.ts +18 -2
  51. package/src/runtime/plugins.ts +236 -0
  52. package/src/runtime/start.ts +16 -1
  53. package/src/runtime/window.ts +3 -0
  54. package/src/server/deck-snapshot.ts +32 -1
  55. package/src/server/dev.ts +9 -3
  56. package/src/server/lock.ts +39 -9
  57. package/src/server/pages.ts +9 -1
  58. package/tsconfig.json +1 -1
@@ -0,0 +1,436 @@
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
+ /** A plugin dek.toml names, with the manifest its lock pins when it has one. */
363
+ export type PinnedPlugin = { name: string; manifest: PluginManifest | undefined };
364
+
365
+ /**
366
+ * The plugins in AGENTS.md: one line each, made from the lock and never from the executable, so
367
+ * every sync writes the same block, the dev server's included. Each line is dek's words about
368
+ * checked fields; the plugin's own summaries stay out. Empty when dek.toml names no plugin.
369
+ */
370
+ export function pluginsSection(plugins: PinnedPlugin[]): string {
371
+ if (plugins.length === 0) {
372
+ return "";
373
+ }
374
+ const lines = plugins.map(({ name, manifest }) => {
375
+ if (!manifest) {
376
+ return `- \`${name}\`: not pinned yet; \`dekc sync\` pins it.`;
377
+ }
378
+ const facts = [
379
+ ...(manifest.check
380
+ ? [`\`dekc lint\` checks ${ruleList(Object.keys(manifest.check.rules))}.`]
381
+ : []),
382
+ ...(manifest.data && manifest.data.length > 0
383
+ ? [
384
+ `Its data: ${projectFirst(manifest.data)
385
+ .map((entry) => `\`${dataPath(name, entry)}\``)
386
+ .join(", ")}.`,
387
+ ]
388
+ : []),
389
+ ...(manifest.page?.keys && manifest.page.keys.length > 0
390
+ ? [
391
+ `Its page takes the ${manifest.page.keys.length === 1 ? "key" : "keys"} ${manifest.page.keys.map((key) => `\`${key}\``).join(", ")}.`,
392
+ ]
393
+ : []),
394
+ ];
395
+ return `- \`${name}\` ${manifest.version}${facts.length > 0 ? `: ${facts.join(" ")}` : ""}`;
396
+ });
397
+ return `
398
+ ## Plugins
399
+
400
+ 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; their findings carry the plugin's rule ids.
401
+
402
+ ${lines.join("\n")}
403
+ `;
404
+ }
405
+
406
+ /** The project's data files, then the deck's, each in the order the manifest gave them. */
407
+ function projectFirst(data: DataEntry[]): DataEntry[] {
408
+ return [...data.filter((entry) => entry.in === "project"), ...data.filter((e) => !e.in)];
409
+ }
410
+
411
+ function ruleList(ids: string[]): string {
412
+ const sorted = [...ids].sort();
413
+ if (sorted.length === 0) {
414
+ return "no rules";
415
+ }
416
+ if (sorted.length === 1) {
417
+ return sorted[0] as string;
418
+ }
419
+ if (sorted.length === 2) {
420
+ return `${sorted[0]} and ${sorted[1]}`;
421
+ }
422
+ const numbers = sorted.map((id) => Number(id.slice(-3)));
423
+ const contiguous = numbers.every((n, i) => i === 0 || n === (numbers[i - 1] as number) + 1);
424
+ return contiguous ? `${sorted[0]} to ${sorted.at(-1)}` : sorted.join(", ");
425
+ }
426
+
427
+ /**
428
+ * A plugin's page as the player and the build carry it: its script, the keys its manifest
429
+ * declares, and each declared data file the deck has, with a version that changes with the text.
430
+ */
431
+ export type PluginPage = {
432
+ name: string;
433
+ keys: string[];
434
+ script: string;
435
+ data: Record<string, { text: string; version: string }>;
436
+ };
@@ -46,7 +46,7 @@ const CHROMIUM_SAYS = /\[err\]|FATAL/;
46
46
  const REASON_CHARS = 280;
47
47
 
48
48
  /** A sentence: up to a `.`, `!`, or `?` that ends a word, outside parentheses, as `(0 vs. 1)` is. */
49
- const SENTENCE = /(?:\([^)]*\)|\((?!\)|$)|[^()!?\.]|[.!?](?!\s|$))+(?:[.!?]+|$)/g;
49
+ const SENTENCE = /(?:\([^)]*\)|\((?!\)|$)|[^()!?.]|[.!?](?!\s|$))+(?:[.!?]+|$)/g;
50
50
 
51
51
  /**
52
52
  * What Chromium said when it failed: its words for a sandbox that cannot start, else its first
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
  }
@@ -251,6 +251,7 @@ export function measureSlideInPage(): SlideMeasure {
251
251
  const own = squash(textNodes.map((node) => node.textContent ?? "").join(" "));
252
252
  // innerText separates block children; textContent would run "item 0item 1" together.
253
253
  // SVG and MathML have no innerText, so they fall back to textContent.
254
+ // biome-ignore lint/performance/useDomNodeTextContent: the rendered line breaks are the point here.
254
255
  const whole = el instanceof HTMLElement ? el.innerText : el.textContent;
255
256
  const text = own || squash(whole ?? "");
256
257
  const parentEl = el.parentElement;
package/src/core/spawn.ts CHANGED
@@ -75,10 +75,16 @@ export async function runPiped(cmd: string[], options: PipedOptions): Promise<Pi
75
75
  hint: `it did not finish within ${Math.round(options.timeoutMs / 1000)}s and was stopped`,
76
76
  });
77
77
  try {
78
- if (options.stdin !== undefined) {
79
- proc.stdin.write(options.stdin);
78
+ try {
79
+ if (options.stdin !== undefined) {
80
+ // A write the pipe cannot take at once returns a promise, which can reject with the pipe.
81
+ await proc.stdin.write(options.stdin);
82
+ }
83
+ await proc.stdin.end();
84
+ } catch {
85
+ // The pipe broke: the command exited without reading all of its stdin, as `head` does, or
86
+ // was killed at its timeout while a write waited on it. Its exit says which, and how it went.
80
87
  }
81
- await proc.stdin.end();
82
88
  const exitCode = await proc.exited;
83
89
  // It finished in time, however long what it left behind keeps the pipes open.
84
90
  stop.clear();
@@ -91,10 +97,6 @@ export async function runPiped(cmd: string[], options: PipedOptions): Promise<Pi
91
97
  if (error instanceof DekError) {
92
98
  throw error;
93
99
  }
94
- // Killing the command breaks the pipe a write to its stdin was waiting on.
95
- if (stop.fired()) {
96
- throw overrun();
97
- }
98
100
  throw new DekError(options.label, { hint: options.hint, cause: error });
99
101
  } finally {
100
102
  stop.clear();
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";
@@ -289,7 +291,7 @@ A build system for talks. Write what you will say; dek builds, measures, and shi
289
291
  - \`dekc lint --visual\` passes.
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
  }