@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.
- package/CHANGELOG.md +37 -0
- package/package.json +12 -5
- package/src/cli/address.ts +48 -11
- package/src/cli/build.ts +3 -1
- package/src/cli/commands.ts +24 -10
- package/src/cli/contract.ts +28 -2
- package/src/cli/flags.ts +5 -0
- package/src/cli/keep-alive.ts +12 -6
- package/src/cli/lint.ts +53 -1
- package/src/cli/main.ts +2 -2
- package/src/cli/sync.ts +21 -6
- package/src/cli/text.ts +20 -2
- package/src/cli/theme.ts +1 -1
- package/src/core/build.ts +4 -0
- package/src/core/config-keys.ts +7 -2
- package/src/core/config.ts +63 -0
- package/src/core/diagnostic.ts +9 -1
- package/src/core/document.ts +28 -3
- package/src/core/lint/context.ts +2 -0
- package/src/core/lint/shared-styles.ts +26 -1
- package/src/core/lint/theme-css.ts +64 -3
- package/src/core/lint.ts +15 -2
- package/src/core/page.ts +1 -0
- package/src/core/plugin-host.ts +669 -0
- package/src/core/plugin.ts +436 -0
- package/src/core/sandbox.ts +1 -1
- package/src/core/sarif.ts +47 -5
- package/src/core/slide-measure.ts +1 -0
- package/src/core/spawn.ts +9 -7
- package/src/core/sync.ts +3 -1
- package/src/plugin-api.ts +126 -0
- package/src/plugins/study/check.ts +128 -0
- package/src/plugins/study/main.ts +47 -0
- package/src/plugins/study/manifest.ts +32 -0
- package/src/plugins/study/page.ts +295 -0
- package/src/plugins/study/place.ts +88 -0
- package/src/plugins/study/sarif.ts +30 -0
- package/src/plugins/study/study.ts +137 -0
- package/src/plugins/study/text.ts +55 -0
- package/src/plugins/study/yaml.ts +226 -0
- package/src/plugins/words/check.ts +109 -0
- package/src/plugins/words/lines.ts +26 -0
- package/src/plugins/words/list.ts +288 -0
- package/src/plugins/words/main.ts +50 -0
- package/src/plugins/words/manifest.ts +36 -0
- package/src/plugins/words/match.ts +60 -0
- package/src/plugins/words/sarif.ts +37 -0
- package/src/plugins/words/text.ts +352 -0
- package/src/plugins/words/yaml.ts +225 -0
- package/src/runtime/player.ts +18 -2
- package/src/runtime/plugins.ts +236 -0
- package/src/runtime/start.ts +16 -1
- package/src/runtime/window.ts +3 -0
- package/src/server/deck-snapshot.ts +32 -1
- package/src/server/dev.ts +9 -3
- package/src/server/lock.ts +39 -9
- package/src/server/pages.ts +9 -1
- 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
|
+
};
|
package/src/core/sandbox.ts
CHANGED
|
@@ -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 = /(?:\([^)]*\)|\((?!\)|$)|[^()
|
|
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 {
|
|
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<{
|
|
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
|
|
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
|
-
|
|
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, ...(
|
|
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
|
-
|
|
79
|
-
|
|
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
|
}
|