sfora-cli 0.10.0 → 0.11.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/README.md +139 -0
- package/dist/SforaFs.js +8 -6
- package/dist/api-client.d.ts +243 -4
- package/dist/api-client.js +248 -20
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/cli.js +317 -26
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +10 -1
- package/dist/format/blocks/dropClosure.js +11 -1
- package/dist/format/callout.d.ts +69 -7
- package/dist/format/callout.js +112 -15
- package/dist/format/checklist.js +11 -4
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +1 -0
- package/dist/format/index.js +4 -0
- package/dist/format/lineGeometry.d.ts +34 -4
- package/dist/format/lineGeometry.js +140 -40
- package/dist/format/lint/appliesTo.d.ts +92 -0
- package/dist/format/lint/appliesTo.js +369 -0
- package/dist/format/lint/config.d.ts +106 -0
- package/dist/format/lint/config.js +205 -0
- package/dist/format/lint/fixAll.d.ts +62 -0
- package/dist/format/lint/fixAll.js +107 -0
- package/dist/format/lint/frontmatterSchema.d.ts +181 -0
- package/dist/format/lint/frontmatterSchema.js +660 -0
- package/dist/format/lint/index.d.ts +34 -5
- package/dist/format/lint/index.js +34 -5
- package/dist/format/lint/lintSource.d.ts +27 -7
- package/dist/format/lint/lintSource.js +67 -33
- package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
- package/dist/format/lint/rules/frontmatter-schema.js +92 -0
- package/dist/format/lint/rules/index.d.ts +2 -1
- package/dist/format/lint/rules/index.js +7 -1
- package/dist/format/lint/rules/malformed-callout.js +25 -16
- package/dist/format/lint/rules/malformed-checklist.js +8 -3
- package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
- package/dist/format/lint/severity.d.ts +15 -0
- package/dist/format/lint/severity.js +50 -0
- package/dist/format/lint/textEdits.d.ts +86 -0
- package/dist/format/lint/textEdits.js +162 -0
- package/dist/format/lint/types.d.ts +44 -8
- package/dist/format/markdown/slug.d.ts +28 -0
- package/dist/format/markdown/slug.js +63 -0
- package/dist/format/plaintext.js +13 -3
- package/dist/format/sheetCellSpans.d.ts +95 -0
- package/dist/format/sheetCellSpans.js +223 -0
- package/dist/format/sheetSelection.d.ts +136 -0
- package/dist/format/sheetSelection.js +282 -0
- package/dist/format/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wikiLinks.d.ts +60 -1
- package/dist/format/wikiLinks.js +195 -9
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- package/dist/opener.d.ts +23 -0
- package/dist/opener.js +26 -0
- package/dist/render.d.ts +132 -0
- package/dist/render.js +208 -0
- package/dist/shell-commands.d.ts +34 -0
- package/dist/shell-commands.js +108 -0
- package/dist/watch.d.ts +79 -0
- package/dist/watch.js +113 -0
- package/dist/web-url.d.ts +39 -0
- package/dist/web-url.js +63 -0
- package/package.json +1 -1
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
// What a document's frontmatter is allowed to say, checked.
|
|
4
|
+
//
|
|
5
|
+
// The last of the four partial rows in the parity charter's §2.1 that was a
|
|
6
|
+
// CAPABILITY gap rather than a decision (card #299): open-knowledge validates
|
|
7
|
+
// frontmatter against JSON Schema — Ajv, four dialects, per-key line anchoring,
|
|
8
|
+
// path-scoped, off by default (`ok/core/markdown/lint/frontmatter-validate.ts`,
|
|
9
|
+
// `okf-frontmatter/registry.ts`) — and sfora parsed frontmatter and never
|
|
10
|
+
// checked a byte of it. This module is sfora's half of that row.
|
|
11
|
+
//
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// WHY NOT AJV
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
//
|
|
16
|
+
// Two reasons, and the second is the one that decides it.
|
|
17
|
+
//
|
|
18
|
+
// The lint core carries no third-party import at all. It is copied verbatim
|
|
19
|
+
// into the published CLI (`packages/sfora/scripts/sync-format.mjs`), so a
|
|
20
|
+
// dependency here is a dependency in the tarball, and the same rules have to
|
|
21
|
+
// run in a Convex mutation, in the editor and in the CLI. `appliesTo.ts` made
|
|
22
|
+
// exactly this trade already, for exactly this reason, and hand-rolled a glob
|
|
23
|
+
// dialect rather than take picomatch.
|
|
24
|
+
//
|
|
25
|
+
// The deciding reason is the VALUE MODEL. Sfora's frontmatter parser
|
|
26
|
+
// (`markdown/yaml.ts`) is a tiny YAML: `key: scalar` and `key: [a, b, c]`,
|
|
27
|
+
// nothing else, and every value it produces is a `string` or a `string[]`.
|
|
28
|
+
// There is no `true`, no `42`, no nested map — not because the parser is
|
|
29
|
+
// unfinished but because that is the shape every sfora fs surface writes. So a
|
|
30
|
+
// general JSON-Schema engine would spend its whole vocabulary on shapes this
|
|
31
|
+
// document model cannot hold, and — much worse — would report `type: "number"`
|
|
32
|
+
// failures on every numeric field in every sfora document, because `42` arrives
|
|
33
|
+
// as `"42"`. Ajv would be confidently wrong here, at volume.
|
|
34
|
+
//
|
|
35
|
+
// What this module does instead: read the JSON Schema an author wrote, and
|
|
36
|
+
// interpret it AGAINST SFORA'S VALUE MODEL. A `type: "number"` is satisfied by
|
|
37
|
+
// a string that spells a number, because that is what a number looks like in
|
|
38
|
+
// this document format. That is a real, stateable semantics rather than a
|
|
39
|
+
// subset with holes in it, and {@link SUPPORTED_KEYWORDS} is the whole of it.
|
|
40
|
+
//
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// NOTHING IS SILENTLY IGNORED
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
//
|
|
45
|
+
// The failure mode a validator has is the one `config.ts` already names: a
|
|
46
|
+
// setting that quietly does nothing, so the document comes back clean and the
|
|
47
|
+
// author believes they are covered. A schema keyword this module does not
|
|
48
|
+
// implement is therefore REPORTED — {@link lintFrontmatterSchemas} is the
|
|
49
|
+
// schema's own linter, the same shape as `lintLintConfig`, and it is what makes
|
|
50
|
+
// "supported subset" a promise instead of an excuse.
|
|
51
|
+
//
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// AND IT IS OFF UNTIL A WORKSPACE TURNS IT ON
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
//
|
|
56
|
+
// No schema ships enabled. `LintConfig.frontmatterSchemas` is empty until a
|
|
57
|
+
// workspace declares one, and a declaration carries the document kind it is
|
|
58
|
+
// for and the paths it applies to. Open-knowledge defaults its whole lint stack
|
|
59
|
+
// to off for the same reason (`ok/core/markdown/lint/plugins.ts:103-108`): a
|
|
60
|
+
// rule about what a document MUST contain is a house rule, and houses differ.
|
|
61
|
+
import { parseYaml } from "../markdown/yaml.js";
|
|
62
|
+
import { compileAppliesTo } from "./appliesTo.js";
|
|
63
|
+
import { isLintSeverity } from "./severity.js";
|
|
64
|
+
/* ------------------------------------------------------------------------- */
|
|
65
|
+
/* The schema dialect */
|
|
66
|
+
/* ------------------------------------------------------------------------- */
|
|
67
|
+
/**
|
|
68
|
+
* Every keyword this module reads, and what it does with it.
|
|
69
|
+
*
|
|
70
|
+
* The list is exported because it is the contract: {@link lintFrontmatterSchemas}
|
|
71
|
+
* reports anything outside it, and the test that keeps the two honest reads
|
|
72
|
+
* this object rather than a second copy of the list.
|
|
73
|
+
*
|
|
74
|
+
* `annotation` keywords are read by nobody and reported by nobody — they are
|
|
75
|
+
* documentation, and a schema that carries a `description` for its agents is
|
|
76
|
+
* doing the right thing.
|
|
77
|
+
*/
|
|
78
|
+
export const SUPPORTED_KEYWORDS = {
|
|
79
|
+
$schema: "annotation",
|
|
80
|
+
$id: "annotation",
|
|
81
|
+
title: "annotation",
|
|
82
|
+
description: "annotation",
|
|
83
|
+
examples: "annotation",
|
|
84
|
+
default: "annotation",
|
|
85
|
+
type: "constraint",
|
|
86
|
+
properties: "constraint",
|
|
87
|
+
required: "constraint",
|
|
88
|
+
additionalProperties: "constraint",
|
|
89
|
+
enum: "constraint",
|
|
90
|
+
const: "constraint",
|
|
91
|
+
pattern: "constraint",
|
|
92
|
+
format: "constraint",
|
|
93
|
+
minLength: "constraint",
|
|
94
|
+
maxLength: "constraint",
|
|
95
|
+
items: "constraint",
|
|
96
|
+
minItems: "constraint",
|
|
97
|
+
maxItems: "constraint",
|
|
98
|
+
uniqueItems: "constraint",
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* The `format` values that mean something here.
|
|
102
|
+
*
|
|
103
|
+
* Deliberately five, and deliberately the five a sfora frontmatter block
|
|
104
|
+
* actually holds — a date, a timestamp, a link, an address, a slug. A `format`
|
|
105
|
+
* outside this set is reported rather than ignored, which is the whole policy
|
|
106
|
+
* of this file in one line.
|
|
107
|
+
*/
|
|
108
|
+
export const SUPPORTED_FORMATS = [
|
|
109
|
+
"date",
|
|
110
|
+
"date-time",
|
|
111
|
+
"uri",
|
|
112
|
+
"email",
|
|
113
|
+
"slug",
|
|
114
|
+
];
|
|
115
|
+
/**
|
|
116
|
+
* The declarations that apply to `path`.
|
|
117
|
+
*
|
|
118
|
+
* Fail-CLOSED where `scopeAdmits` fails open, and the asymmetry is deliberate.
|
|
119
|
+
* A scoped RULE narrows something that would otherwise run everywhere, so a
|
|
120
|
+
* document whose path we do not know keeps the rule (see `config.ts`). A
|
|
121
|
+
* frontmatter schema is the opposite: it exists only for a kind of document,
|
|
122
|
+
* and running a `decision` schema over an unknown document would report a
|
|
123
|
+
* missing `status` on a chat message. Silence is the cheaper failure here, and
|
|
124
|
+
* a declaration with NO `appliesTo` still runs on everything, which is the door
|
|
125
|
+
* for a workspace that means it.
|
|
126
|
+
*/
|
|
127
|
+
export function selectFrontmatterSchemas(declarations, path) {
|
|
128
|
+
if (!declarations || declarations.length === 0)
|
|
129
|
+
return [];
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const declaration of declarations) {
|
|
132
|
+
const scope = compileAppliesTo(declaration.appliesTo);
|
|
133
|
+
if (scope.matchesEverything || scope.matches(path)) {
|
|
134
|
+
out.push({ declaration, scope });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The default level for each kind of violation, in the vocabulary of
|
|
141
|
+
* ./severity's ladder — which is ordered by WHAT THE DOCUMENT LOSES.
|
|
142
|
+
*
|
|
143
|
+
* `missing` and `invalid` are both `warning`: the bytes are there, and what
|
|
144
|
+
* they say is not what the workspace will read. `unknown` is `info`: the
|
|
145
|
+
* author wrote something, it renders, and nothing will ever look at it —
|
|
146
|
+
* which is the ladder's definition of `info` word for word.
|
|
147
|
+
*
|
|
148
|
+
* Nothing here is `error`. `error` on this ladder means the document as a whole
|
|
149
|
+
* stops meaning what it says, and the one rule that claims it (an unclosed
|
|
150
|
+
* `---` fence) earns it. A `status:` the schema does not recognise costs the
|
|
151
|
+
* document one field, not its identity, and a validator that shouted would
|
|
152
|
+
* teach people to turn the validator off.
|
|
153
|
+
*/
|
|
154
|
+
const DEFAULT_SEVERITY = {
|
|
155
|
+
missing: "warning",
|
|
156
|
+
invalid: "warning",
|
|
157
|
+
unknown: "info",
|
|
158
|
+
};
|
|
159
|
+
function severityFor(declaration, kind) {
|
|
160
|
+
const declared = declaration.severity;
|
|
161
|
+
if (declared !== undefined && isLintSeverity(declared))
|
|
162
|
+
return declared;
|
|
163
|
+
return DEFAULT_SEVERITY[kind];
|
|
164
|
+
}
|
|
165
|
+
/** `["a", "b"]` -> `` `a`, `b` ``, the one spelling every message uses. */
|
|
166
|
+
function list(values) {
|
|
167
|
+
return values.map((value) => `\`${String(value)}\``).join(", ");
|
|
168
|
+
}
|
|
169
|
+
const FORMAT_TEST = {
|
|
170
|
+
// Calendar-shaped rather than calendar-correct: `2026-02-31` passes. A lint
|
|
171
|
+
// rule that rejected a real date because February is short would be arguing
|
|
172
|
+
// about a timezone, and this one is about a field being a date at all.
|
|
173
|
+
date: (value) => /^\d{4}-\d{2}-\d{2}$/.test(value),
|
|
174
|
+
"date-time": (value) => /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?([Zz]|[+-]\d{2}:?\d{2})?$/.test(value),
|
|
175
|
+
// A scheme and something after it. Not a URL parser: `new URL` would accept
|
|
176
|
+
// `a:b` and reject a bare `example.com`, and neither answer helps an author.
|
|
177
|
+
uri: (value) => /^[a-z][a-z0-9+.-]*:\/\/\S+$|^[a-z][a-z0-9+.-]*:\S+$/i.test(value),
|
|
178
|
+
email: (value) => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value),
|
|
179
|
+
slug: (value) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(value),
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Does `value` spell a `type`?
|
|
183
|
+
*
|
|
184
|
+
* THE WHOLE INTERPRETATION LIVES HERE. Frontmatter values arrive as strings
|
|
185
|
+
* because `markdown/yaml.ts` produces strings; a schema that says `number`
|
|
186
|
+
* means "a number is written here", and this is where those two facts meet.
|
|
187
|
+
* `integer`, `number` and `boolean` are checked against the SPELLING; `string`
|
|
188
|
+
* accepts any scalar; `array` accepts the one list form the parser produces.
|
|
189
|
+
*/
|
|
190
|
+
function matchesType(value, type) {
|
|
191
|
+
const isArray = Array.isArray(value);
|
|
192
|
+
switch (type) {
|
|
193
|
+
case "array":
|
|
194
|
+
return isArray;
|
|
195
|
+
case "string":
|
|
196
|
+
return !isArray;
|
|
197
|
+
case "number":
|
|
198
|
+
return !isArray && value.trim() !== "" && Number.isFinite(Number(value));
|
|
199
|
+
case "integer":
|
|
200
|
+
return !isArray && /^[+-]?\d+$/.test(value.trim());
|
|
201
|
+
case "boolean":
|
|
202
|
+
return !isArray && /^(true|false)$/i.test(value.trim());
|
|
203
|
+
case "object":
|
|
204
|
+
// Sfora's frontmatter has no nested maps at all, so a schema asking for
|
|
205
|
+
// one is asking for something this format cannot hold. Reported by
|
|
206
|
+
// `lintFrontmatterSchemas` as an unsupported type rather than failed
|
|
207
|
+
// here on every document.
|
|
208
|
+
return false;
|
|
209
|
+
case "null":
|
|
210
|
+
return !isArray && value.trim() === "";
|
|
211
|
+
default:
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** The article-free noun a message uses for a type. */
|
|
216
|
+
function typeWord(type) {
|
|
217
|
+
return type === "array" ? "a list" : `a ${type}`;
|
|
218
|
+
}
|
|
219
|
+
/** Read one property schema into the checks it asks for, in report order. */
|
|
220
|
+
function constraintsOf(property) {
|
|
221
|
+
const out = [];
|
|
222
|
+
const type = property.type;
|
|
223
|
+
if (typeof type === "string") {
|
|
224
|
+
out.push({
|
|
225
|
+
keyword: "type",
|
|
226
|
+
check: (value) => matchesType(value, type)
|
|
227
|
+
? null
|
|
228
|
+
: `must be ${typeWord(type)}, and this is ${Array.isArray(value) ? "a list" : `\`${value}\``}`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
const constValue = property.const;
|
|
232
|
+
if (constValue !== undefined) {
|
|
233
|
+
const expected = String(constValue);
|
|
234
|
+
out.push({
|
|
235
|
+
keyword: "const",
|
|
236
|
+
check: (value) => !Array.isArray(value) && value === expected
|
|
237
|
+
? null
|
|
238
|
+
: `must be \`${expected}\``,
|
|
239
|
+
suggest: () => expected,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
const allowed = property.enum;
|
|
243
|
+
if (Array.isArray(allowed)) {
|
|
244
|
+
const spellings = allowed.map(String);
|
|
245
|
+
out.push({
|
|
246
|
+
keyword: "enum",
|
|
247
|
+
check: (value) => !Array.isArray(value) && spellings.includes(value)
|
|
248
|
+
? null
|
|
249
|
+
: `must be one of ${list(spellings)}`,
|
|
250
|
+
// The near miss, and only the near miss. An author who typed `Shipped`
|
|
251
|
+
// for `shipped` gets a one-click fix; an author who typed something else
|
|
252
|
+
// entirely gets the list and no guess, because a validator that puts
|
|
253
|
+
// words in a document is worse than one that points at a line.
|
|
254
|
+
suggest: (value) => Array.isArray(value)
|
|
255
|
+
? undefined
|
|
256
|
+
: spellings.find((candidate) => candidate.toLowerCase() === value.trim().toLowerCase()),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const pattern = property.pattern;
|
|
260
|
+
if (typeof pattern === "string") {
|
|
261
|
+
let re = null;
|
|
262
|
+
try {
|
|
263
|
+
re = new RegExp(pattern);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// Reported by `lintFrontmatterSchemas`. Refusing every document because
|
|
267
|
+
// the SCHEMA is broken would blame the wrong author.
|
|
268
|
+
re = null;
|
|
269
|
+
}
|
|
270
|
+
if (re) {
|
|
271
|
+
const test = re;
|
|
272
|
+
out.push({
|
|
273
|
+
keyword: "pattern",
|
|
274
|
+
check: (value) => !Array.isArray(value) && test.test(value)
|
|
275
|
+
? null
|
|
276
|
+
: `must match \`${pattern}\``,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const format = property.format;
|
|
281
|
+
if (typeof format === "string" && format in FORMAT_TEST) {
|
|
282
|
+
const test = FORMAT_TEST[format];
|
|
283
|
+
out.push({
|
|
284
|
+
keyword: "format",
|
|
285
|
+
check: (value) => !Array.isArray(value) && test(value) ? null : `must be a ${format}`,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
const minLength = property.minLength;
|
|
289
|
+
if (typeof minLength === "number") {
|
|
290
|
+
out.push({
|
|
291
|
+
keyword: "minLength",
|
|
292
|
+
check: (value) => !Array.isArray(value) && value.length >= minLength
|
|
293
|
+
? null
|
|
294
|
+
: `must be at least ${minLength} characters`,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
const maxLength = property.maxLength;
|
|
298
|
+
if (typeof maxLength === "number") {
|
|
299
|
+
out.push({
|
|
300
|
+
keyword: "maxLength",
|
|
301
|
+
check: (value) => !Array.isArray(value) && value.length <= maxLength
|
|
302
|
+
? null
|
|
303
|
+
: `must be at most ${maxLength} characters`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const minItems = property.minItems;
|
|
307
|
+
if (typeof minItems === "number") {
|
|
308
|
+
out.push({
|
|
309
|
+
keyword: "minItems",
|
|
310
|
+
check: (value) => Array.isArray(value) && value.length >= minItems
|
|
311
|
+
? null
|
|
312
|
+
: `must list at least ${minItems}`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
const maxItems = property.maxItems;
|
|
316
|
+
if (typeof maxItems === "number") {
|
|
317
|
+
out.push({
|
|
318
|
+
keyword: "maxItems",
|
|
319
|
+
check: (value) => Array.isArray(value) && value.length <= maxItems
|
|
320
|
+
? null
|
|
321
|
+
: `must list at most ${maxItems}`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (property.uniqueItems === true) {
|
|
325
|
+
out.push({
|
|
326
|
+
keyword: "uniqueItems",
|
|
327
|
+
check: (value) => !Array.isArray(value) || new Set(value).size === value.length
|
|
328
|
+
? null
|
|
329
|
+
: "must not repeat an entry",
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
const items = property.items;
|
|
333
|
+
if (isSchemaObject(items)) {
|
|
334
|
+
const inner = constraintsOf(items);
|
|
335
|
+
out.push({
|
|
336
|
+
keyword: "items",
|
|
337
|
+
check: (value) => {
|
|
338
|
+
if (!Array.isArray(value))
|
|
339
|
+
return null;
|
|
340
|
+
for (const entry of value) {
|
|
341
|
+
for (const constraint of inner) {
|
|
342
|
+
const failure = constraint.check(entry);
|
|
343
|
+
if (failure)
|
|
344
|
+
return `has an entry that ${failure}`;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
function isSchemaObject(value) {
|
|
354
|
+
return (typeof value === "object" && value !== null && !Array.isArray(value));
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Check one document's frontmatter against every schema declared for it.
|
|
358
|
+
*
|
|
359
|
+
* `data` is what `markdown/yaml.ts` read, so this function grades the values
|
|
360
|
+
* SFORA WILL ACTUALLY USE rather than a second reading of the same bytes. That
|
|
361
|
+
* matters more than it sounds: a key the tiny YAML drops (no colon, a nested
|
|
362
|
+
* map) is a key the workspace does not have, and a validator that read it with
|
|
363
|
+
* a fuller parser would pass a document whose `status` no consumer can see.
|
|
364
|
+
* The structural half of that — telling the author their line was dropped —
|
|
365
|
+
* belongs to `malformed-frontmatter`, which is why this rule stays silent about
|
|
366
|
+
* it instead of saying the same thing twice.
|
|
367
|
+
*/
|
|
368
|
+
export function validateFrontmatter(data, schemas) {
|
|
369
|
+
const out = [];
|
|
370
|
+
for (const { declaration } of schemas) {
|
|
371
|
+
const { schema, kind } = declaration;
|
|
372
|
+
const properties = isSchemaObject(schema.properties) ? schema.properties : {};
|
|
373
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
374
|
+
for (const name of required) {
|
|
375
|
+
if (typeof name !== "string")
|
|
376
|
+
continue;
|
|
377
|
+
if (name in data)
|
|
378
|
+
continue;
|
|
379
|
+
const property = isSchemaObject(properties[name])
|
|
380
|
+
? properties[name]
|
|
381
|
+
: undefined;
|
|
382
|
+
// The suggestion goes in the SENTENCE, not into a fix. Where a key
|
|
383
|
+
// belongs inside a metadata block is the author's ordering, and a
|
|
384
|
+
// validator that writes a line into a document it is only supposed to
|
|
385
|
+
// grade has stopped being a linter — so a schema that names one obvious
|
|
386
|
+
// value says so, and the author types it.
|
|
387
|
+
const only = onlyValueOf(property);
|
|
388
|
+
out.push({
|
|
389
|
+
kind: "missing",
|
|
390
|
+
key: name,
|
|
391
|
+
documentKind: kind,
|
|
392
|
+
keyword: "required",
|
|
393
|
+
severity: severityFor(declaration, "missing"),
|
|
394
|
+
message: only === undefined
|
|
395
|
+
? `A \`${kind}\` document needs \`${name}\` in its frontmatter.`
|
|
396
|
+
: `A \`${kind}\` document needs \`${name}\` in its frontmatter — the schema allows \`${only}\`.`,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
for (const [name, raw] of Object.entries(data)) {
|
|
400
|
+
const property = properties[name];
|
|
401
|
+
if (!isSchemaObject(property)) {
|
|
402
|
+
if (schema.additionalProperties === false) {
|
|
403
|
+
out.push({
|
|
404
|
+
kind: "unknown",
|
|
405
|
+
key: name,
|
|
406
|
+
documentKind: kind,
|
|
407
|
+
keyword: "additionalProperties",
|
|
408
|
+
severity: severityFor(declaration, "unknown"),
|
|
409
|
+
message: `Nothing reads \`${name}\` on a \`${kind}\` document — it is not in the schema.`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
for (const constraint of constraintsOf(property)) {
|
|
415
|
+
const failure = constraint.check(raw);
|
|
416
|
+
if (failure === null)
|
|
417
|
+
continue;
|
|
418
|
+
const suggestion = constraint.suggest?.(raw);
|
|
419
|
+
out.push({
|
|
420
|
+
kind: "invalid",
|
|
421
|
+
key: name,
|
|
422
|
+
documentKind: kind,
|
|
423
|
+
keyword: constraint.keyword,
|
|
424
|
+
severity: severityFor(declaration, "invalid"),
|
|
425
|
+
message: `On a \`${kind}\` document, \`${name}\` ${failure}.`,
|
|
426
|
+
...(suggestion === undefined ? {} : { suggestion }),
|
|
427
|
+
});
|
|
428
|
+
// One complaint per key per schema. An author who wrote `Shipped`
|
|
429
|
+
// where the enum wants `shipped` does not need to be told separately
|
|
430
|
+
// that it also failed a `pattern` derived from the same list.
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return out;
|
|
436
|
+
}
|
|
437
|
+
/** The one value a missing key could obviously take, when the schema names it. */
|
|
438
|
+
function onlyValueOf(property) {
|
|
439
|
+
if (!property)
|
|
440
|
+
return undefined;
|
|
441
|
+
if (property.const !== undefined)
|
|
442
|
+
return String(property.const);
|
|
443
|
+
if (property.default !== undefined && !Array.isArray(property.default)) {
|
|
444
|
+
return String(property.default);
|
|
445
|
+
}
|
|
446
|
+
if (Array.isArray(property.enum) && property.enum.length === 1) {
|
|
447
|
+
return String(property.enum[0]);
|
|
448
|
+
}
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Read a frontmatter block's key lines.
|
|
453
|
+
*
|
|
454
|
+
* Same reading as `parseYaml` — a top-level `key:` and nothing indented — so
|
|
455
|
+
* the line a mark lands on is the line the value came from. Duplicate keys
|
|
456
|
+
* resolve to the FIRST occurrence, which is where `malformed-frontmatter`
|
|
457
|
+
* already points its own duplicate mark, so the two rules agree about which
|
|
458
|
+
* line a key is on.
|
|
459
|
+
*/
|
|
460
|
+
export function frontmatterKeyLines(lines, block) {
|
|
461
|
+
const out = new Map();
|
|
462
|
+
for (let i = block.open + 1; i < block.close; i++) {
|
|
463
|
+
const raw = lines[i] ?? "";
|
|
464
|
+
if (raw.startsWith(" ") || raw.startsWith("\t"))
|
|
465
|
+
continue;
|
|
466
|
+
const text = raw.trim();
|
|
467
|
+
if (text === "" || text.startsWith("#"))
|
|
468
|
+
continue;
|
|
469
|
+
const colon = text.indexOf(":");
|
|
470
|
+
if (colon <= 0)
|
|
471
|
+
continue;
|
|
472
|
+
const key = text.slice(0, colon).trim();
|
|
473
|
+
if (key === "" || out.has(key))
|
|
474
|
+
continue;
|
|
475
|
+
out.set(key, i);
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
/** The block's body, for `parseYaml`. */
|
|
480
|
+
export function frontmatterBody(lines, block) {
|
|
481
|
+
return lines.slice(block.open + 1, block.close).join("\n");
|
|
482
|
+
}
|
|
483
|
+
/** Read a document's frontmatter the way sfora reads it. */
|
|
484
|
+
export function readFrontmatter(lines, block) {
|
|
485
|
+
return parseYaml(frontmatterBody(lines, block));
|
|
486
|
+
}
|
|
487
|
+
/* ------------------------------------------------------------------------- */
|
|
488
|
+
/* The schema's own linter */
|
|
489
|
+
/* ------------------------------------------------------------------------- */
|
|
490
|
+
export const FRONTMATTER_SCHEMA_RULE_IDS = {
|
|
491
|
+
unknownKeyword: "sfora/schema-unknown-keyword",
|
|
492
|
+
unsupportedType: "sfora/schema-unsupported-type",
|
|
493
|
+
unsupportedFormat: "sfora/schema-unsupported-format",
|
|
494
|
+
invalidPattern: "sfora/schema-invalid-pattern",
|
|
495
|
+
invalidSeverity: "sfora/schema-invalid-severity",
|
|
496
|
+
invalidGlob: "sfora/schema-invalid-glob",
|
|
497
|
+
suspiciousGlob: "sfora/schema-suspicious-glob",
|
|
498
|
+
requiredNotDeclared: "sfora/schema-required-not-declared",
|
|
499
|
+
emptyKind: "sfora/schema-no-kind",
|
|
500
|
+
};
|
|
501
|
+
/**
|
|
502
|
+
* Types {@link matchesType} can answer for. `object` is deliberately absent.
|
|
503
|
+
*
|
|
504
|
+
* Exported for the same reason {@link SUPPORTED_FORMATS} is: it is a promise
|
|
505
|
+
* about behaviour, and its test drives the spelling each type accepts off this
|
|
506
|
+
* list rather than off a second copy of it. A type added here without a reading
|
|
507
|
+
* in `matchesType` fails that test.
|
|
508
|
+
*/
|
|
509
|
+
export const SUPPORTED_TYPES = [
|
|
510
|
+
"string",
|
|
511
|
+
"array",
|
|
512
|
+
"number",
|
|
513
|
+
"integer",
|
|
514
|
+
"boolean",
|
|
515
|
+
"null",
|
|
516
|
+
];
|
|
517
|
+
/**
|
|
518
|
+
* Read the declarations back and report every part of them that will silently
|
|
519
|
+
* do nothing.
|
|
520
|
+
*
|
|
521
|
+
* This is the promise that makes a supported SUBSET honest. An author writes a
|
|
522
|
+
* schema, this module reads the keywords it knows, and without this function
|
|
523
|
+
* every keyword it does not know would be a constraint the author believes is
|
|
524
|
+
* being enforced and that nothing enforces. `config.ts` calls that "the worst
|
|
525
|
+
* thing a linter can do, because the symptom is a clean document"; a validator
|
|
526
|
+
* has the same failure mode one layer up.
|
|
527
|
+
*/
|
|
528
|
+
export function lintFrontmatterSchemas(declarations) {
|
|
529
|
+
const out = [];
|
|
530
|
+
if (!declarations)
|
|
531
|
+
return out;
|
|
532
|
+
declarations.forEach((declaration, index) => {
|
|
533
|
+
if (typeof declaration.kind !== "string" || declaration.kind.trim() === "") {
|
|
534
|
+
out.push({
|
|
535
|
+
severity: "warning",
|
|
536
|
+
ruleId: FRONTMATTER_SCHEMA_RULE_IDS.emptyKind,
|
|
537
|
+
message: "This schema has no `kind`, so its marks cannot say which kind of document they are about.",
|
|
538
|
+
at: [index, "kind"],
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
const severity = declaration.severity;
|
|
542
|
+
if (severity !== undefined && !isLintSeverity(severity)) {
|
|
543
|
+
out.push({
|
|
544
|
+
severity: "warning",
|
|
545
|
+
ruleId: FRONTMATTER_SCHEMA_RULE_IDS.invalidSeverity,
|
|
546
|
+
message: `\`${String(severity)}\` is not a severity, so this schema reports at its defaults.`,
|
|
547
|
+
at: [index, "severity"],
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
const compiled = compileAppliesTo(declaration.appliesTo);
|
|
551
|
+
const patterns = declaration.appliesTo === undefined
|
|
552
|
+
? []
|
|
553
|
+
: typeof declaration.appliesTo === "string"
|
|
554
|
+
? [declaration.appliesTo]
|
|
555
|
+
: [...declaration.appliesTo];
|
|
556
|
+
for (const bad of compiled.invalid) {
|
|
557
|
+
out.push({
|
|
558
|
+
severity: "warning",
|
|
559
|
+
ruleId: FRONTMATTER_SCHEMA_RULE_IDS.invalidGlob,
|
|
560
|
+
message: `\`${bad.pattern}\` is not a usable path pattern (${bad.detail}), so this schema runs on nothing.`,
|
|
561
|
+
at: [index, "appliesTo", ...indexOf(patterns, bad.pattern)],
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
for (const doubt of compiled.suspicious) {
|
|
565
|
+
const hint = doubt.suggestion ? ` Did you mean \`${doubt.suggestion}\`?` : "";
|
|
566
|
+
out.push({
|
|
567
|
+
severity: "warning",
|
|
568
|
+
ruleId: FRONTMATTER_SCHEMA_RULE_IDS.suspiciousGlob,
|
|
569
|
+
message: `\`${doubt.pattern}\` does not look like a document path (${doubt.reason}).${hint}`,
|
|
570
|
+
at: [index, "appliesTo", ...indexOf(patterns, doubt.pattern)],
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
const schema = declaration.schema;
|
|
574
|
+
if (!isSchemaObject(schema))
|
|
575
|
+
return;
|
|
576
|
+
const at = [index, "schema"];
|
|
577
|
+
reportKeywords(schema, at, out);
|
|
578
|
+
const properties = isSchemaObject(schema.properties) ? schema.properties : {};
|
|
579
|
+
for (const [name, property] of Object.entries(properties)) {
|
|
580
|
+
if (!isSchemaObject(property))
|
|
581
|
+
continue;
|
|
582
|
+
reportProperty(property, [...at, "properties", name], out);
|
|
583
|
+
}
|
|
584
|
+
// A `required` key with no `properties` entry is the quietest hole of all:
|
|
585
|
+
// the presence check runs, and every constraint the author thinks they
|
|
586
|
+
// wrote for that key is somewhere else entirely.
|
|
587
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
588
|
+
required.forEach((name, i) => {
|
|
589
|
+
if (typeof name !== "string")
|
|
590
|
+
return;
|
|
591
|
+
if (name in properties)
|
|
592
|
+
return;
|
|
593
|
+
out.push({
|
|
594
|
+
severity: "info",
|
|
595
|
+
ruleId: FRONTMATTER_SCHEMA_RULE_IDS.requiredNotDeclared,
|
|
596
|
+
message: `\`${name}\` is required but has no entry under \`properties\`, so only its presence is checked.`,
|
|
597
|
+
at: [...at, "required", i],
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
function reportKeywords(schema, at, out) {
|
|
604
|
+
for (const keyword of Object.keys(schema)) {
|
|
605
|
+
if (keyword in SUPPORTED_KEYWORDS)
|
|
606
|
+
continue;
|
|
607
|
+
out.push({
|
|
608
|
+
severity: "warning",
|
|
609
|
+
ruleId: FRONTMATTER_SCHEMA_RULE_IDS.unknownKeyword,
|
|
610
|
+
message: `\`${keyword}\` is not a keyword sfora checks, so it constrains nothing. Supported: ${list(Object.keys(SUPPORTED_KEYWORDS).filter((name) => SUPPORTED_KEYWORDS[name] === "constraint"))}.`,
|
|
611
|
+
at: [...at, keyword],
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function reportProperty(property, at, out) {
|
|
616
|
+
reportKeywords(property, at, out);
|
|
617
|
+
const type = property.type;
|
|
618
|
+
if (typeof type === "string" &&
|
|
619
|
+
!SUPPORTED_TYPES.includes(type)) {
|
|
620
|
+
out.push({
|
|
621
|
+
severity: "warning",
|
|
622
|
+
ruleId: FRONTMATTER_SCHEMA_RULE_IDS.unsupportedType,
|
|
623
|
+
message: type === "object"
|
|
624
|
+
? "Sfora frontmatter holds scalars and flat lists, so an `object` property can never be satisfied."
|
|
625
|
+
: `\`${type}\` is not a type sfora checks. Supported: ${list(SUPPORTED_TYPES)}.`,
|
|
626
|
+
at: [...at, "type"],
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
const format = property.format;
|
|
630
|
+
if (typeof format === "string" && !(format in FORMAT_TEST)) {
|
|
631
|
+
out.push({
|
|
632
|
+
severity: "warning",
|
|
633
|
+
ruleId: FRONTMATTER_SCHEMA_RULE_IDS.unsupportedFormat,
|
|
634
|
+
message: `\`${format}\` is not a format sfora checks, so it constrains nothing. Supported: ${list(SUPPORTED_FORMATS)}.`,
|
|
635
|
+
at: [...at, "format"],
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
const pattern = property.pattern;
|
|
639
|
+
if (typeof pattern === "string") {
|
|
640
|
+
try {
|
|
641
|
+
new RegExp(pattern);
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
out.push({
|
|
645
|
+
severity: "warning",
|
|
646
|
+
ruleId: FRONTMATTER_SCHEMA_RULE_IDS.invalidPattern,
|
|
647
|
+
message: `\`${pattern}\` is not a usable regular expression (${err instanceof Error ? err.message : String(err)}), so it constrains nothing.`,
|
|
648
|
+
at: [...at, "pattern"],
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const items = property.items;
|
|
653
|
+
if (isSchemaObject(items))
|
|
654
|
+
reportProperty(items, [...at, "items"], out);
|
|
655
|
+
}
|
|
656
|
+
/** `["appliesTo", 2]`, or just the tail when the pattern is not found. */
|
|
657
|
+
function indexOf(patterns, pattern) {
|
|
658
|
+
const index = patterns.findIndex((p) => p.trim() === pattern.trim());
|
|
659
|
+
return index === -1 ? [] : [index];
|
|
660
|
+
}
|