@ferrule-io/ok-fine 0.0.0-stage → 0.3.6

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.
@@ -0,0 +1,460 @@
1
+ import { posix } from "node:path";
2
+ import { parseFrontmatter, splitFrontmatter } from "./frontmatter.js";
3
+ import { computationBlocks, extractFootnoteLabels, extractLinks, hasLegacyCitations } from "./markdown.js";
4
+ import { ISO_DATETIME, isStale, parseActor } from "./semantics.js";
5
+ export function lintConceptFile(path, text, ctx) {
6
+ const issues = [];
7
+ const split = splitFrontmatter(text);
8
+ if (!split) {
9
+ issues.push({
10
+ severity: "error",
11
+ code: "missing_frontmatter",
12
+ path,
13
+ message: "file is missing YAML frontmatter delimited by --- lines",
14
+ });
15
+ return issues;
16
+ }
17
+ const parsed = parseFrontmatter(split.yaml);
18
+ if ("error" in parsed) {
19
+ issues.push({
20
+ severity: "error",
21
+ code: parsed.error,
22
+ path,
23
+ message: parsed.message,
24
+ });
25
+ return issues;
26
+ }
27
+ const { data } = parsed;
28
+ const body = split.body;
29
+ // Concept ID = path without .md
30
+ const conceptId = path.endsWith(".md") ? path.slice(0, -3) : path;
31
+ // Type check (required)
32
+ if (typeof data.type !== "string" || data.type.trim().length === 0) {
33
+ issues.push({
34
+ severity: "error",
35
+ code: "missing_type",
36
+ path,
37
+ message: "frontmatter must contain a non-empty string type",
38
+ });
39
+ }
40
+ // Recommended fields type validation
41
+ if ("title" in data && data.title != null && typeof data.title !== "string") {
42
+ issues.push({
43
+ severity: "warning",
44
+ code: "invalid_title",
45
+ path,
46
+ message: "title must be a string",
47
+ });
48
+ }
49
+ if ("description" in data && data.description != null && typeof data.description !== "string") {
50
+ issues.push({
51
+ severity: "warning",
52
+ code: "invalid_description",
53
+ path,
54
+ message: "description must be a string",
55
+ });
56
+ }
57
+ if ("tags" in data && data.tags != null) {
58
+ if (!Array.isArray(data.tags) || data.tags.some((t) => typeof t !== "string")) {
59
+ issues.push({
60
+ severity: "warning",
61
+ code: "invalid_tags",
62
+ path,
63
+ message: "tags must be a list of strings",
64
+ });
65
+ }
66
+ }
67
+ if ("status" in data && data.status != null) {
68
+ if (data.status !== "draft" && data.status !== "stable" && data.status !== "deprecated") {
69
+ issues.push({
70
+ severity: "warning",
71
+ code: "invalid_status",
72
+ path,
73
+ message: 'status must be "draft", "stable", or "deprecated"',
74
+ });
75
+ }
76
+ }
77
+ // Timestamps validation
78
+ if (data.generated && typeof data.generated === "object") {
79
+ if (!("by" in data.generated) || typeof data.generated.by !== "string" || data.generated.by.trim().length === 0) {
80
+ issues.push({
81
+ severity: "warning",
82
+ code: "generated_missing_by",
83
+ path,
84
+ message: "generated must contain a non-empty string by",
85
+ });
86
+ }
87
+ else {
88
+ if (!parseActor(data.generated.by)) {
89
+ issues.push({
90
+ severity: "warning",
91
+ code: "invalid_actor",
92
+ path,
93
+ message: `generated.by "${data.generated.by}" is not a valid actor`,
94
+ });
95
+ }
96
+ }
97
+ if ("at" in data.generated && data.generated.at != null) {
98
+ if (typeof data.generated.at !== "string" || !ISO_DATETIME.test(data.generated.at)) {
99
+ issues.push({
100
+ severity: "warning",
101
+ code: "invalid_timestamp",
102
+ path,
103
+ message: "generated.at must be an ISO 8601 string with explicit UTC offset",
104
+ });
105
+ }
106
+ }
107
+ }
108
+ if ("stale_after" in data && data.stale_after != null) {
109
+ if (typeof data.stale_after !== "string" || !ISO_DATETIME.test(data.stale_after)) {
110
+ issues.push({
111
+ severity: "warning",
112
+ code: "invalid_timestamp",
113
+ path,
114
+ message: "stale_after must be an ISO 8601 string with explicit UTC offset",
115
+ });
116
+ }
117
+ }
118
+ if (data.usage_window && typeof data.usage_window === "object") {
119
+ if ("from" in data.usage_window && data.usage_window.from != null) {
120
+ if (typeof data.usage_window.from !== "string" || !ISO_DATETIME.test(data.usage_window.from)) {
121
+ issues.push({
122
+ severity: "warning",
123
+ code: "invalid_timestamp",
124
+ path,
125
+ message: "usage_window.from must be an ISO 8601 string with explicit UTC offset",
126
+ });
127
+ }
128
+ }
129
+ if ("to" in data.usage_window && data.usage_window.to != null) {
130
+ if (typeof data.usage_window.to !== "string" || !ISO_DATETIME.test(data.usage_window.to)) {
131
+ issues.push({
132
+ severity: "warning",
133
+ code: "invalid_timestamp",
134
+ path,
135
+ message: "usage_window.to must be an ISO 8601 string with explicit UTC offset",
136
+ });
137
+ }
138
+ }
139
+ }
140
+ // Verified validation
141
+ if ("verified" in data && data.verified != null) {
142
+ const raw = data.verified;
143
+ const isMapObj = typeof raw === "object" && !Array.isArray(raw);
144
+ const isList = Array.isArray(raw);
145
+ if (!isMapObj && !isList) {
146
+ issues.push({
147
+ severity: "warning",
148
+ code: "invalid_verified",
149
+ path,
150
+ message: "verified must be a mapping or list of mappings",
151
+ });
152
+ }
153
+ else {
154
+ const entries = isList ? raw : [raw];
155
+ for (const entry of entries) {
156
+ if (!entry || typeof entry !== "object") {
157
+ issues.push({
158
+ severity: "warning",
159
+ code: "invalid_verified",
160
+ path,
161
+ message: "verified entry must be an object with string by and at",
162
+ });
163
+ continue;
164
+ }
165
+ const byVal = "by" in entry && typeof entry.by === "string" ? entry.by : null;
166
+ const atVal = "at" in entry && typeof entry.at === "string" ? entry.at : null;
167
+ if (!byVal || !atVal) {
168
+ issues.push({
169
+ severity: "warning",
170
+ code: "invalid_verified",
171
+ path,
172
+ message: "verified entry must contain non-empty string by and at",
173
+ });
174
+ }
175
+ else {
176
+ if (!parseActor(byVal)) {
177
+ issues.push({
178
+ severity: "warning",
179
+ code: "invalid_actor",
180
+ path,
181
+ message: `verified.by "${byVal}" is not a valid actor`,
182
+ });
183
+ }
184
+ if (!ISO_DATETIME.test(atVal)) {
185
+ issues.push({
186
+ severity: "warning",
187
+ code: "invalid_timestamp",
188
+ path,
189
+ message: "verified[].at must be an ISO 8601 string with explicit UTC offset",
190
+ });
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ // Sources validation
197
+ const sourceIds = new Set();
198
+ if ("sources" in data && data.sources != null) {
199
+ if (Array.isArray(data.sources)) {
200
+ for (const src of data.sources) {
201
+ if (!src || typeof src !== "object") {
202
+ continue;
203
+ }
204
+ if (!("resource" in src) || typeof src.resource !== "string" || src.resource.trim().length === 0) {
205
+ issues.push({
206
+ severity: "warning",
207
+ code: "source_missing_resource",
208
+ path,
209
+ message: "source must contain a non-empty string resource",
210
+ });
211
+ }
212
+ if ("id" in src && typeof src.id === "string" && src.id.trim().length > 0) {
213
+ if (sourceIds.has(src.id)) {
214
+ issues.push({
215
+ severity: "warning",
216
+ code: "duplicate_source_id",
217
+ path,
218
+ message: `duplicate source id "${src.id}"`,
219
+ });
220
+ }
221
+ else {
222
+ sourceIds.add(src.id);
223
+ }
224
+ }
225
+ if ("last_modified" in src && src.last_modified != null) {
226
+ if (typeof src.last_modified !== "string" || !ISO_DATETIME.test(src.last_modified)) {
227
+ issues.push({
228
+ severity: "warning",
229
+ code: "invalid_timestamp",
230
+ path,
231
+ message: "sources[].last_modified must be an ISO 8601 string with explicit UTC offset",
232
+ });
233
+ }
234
+ }
235
+ if ("usage_window" in src && src.usage_window && typeof src.usage_window === "object") {
236
+ if ("from" in src.usage_window && src.usage_window.from != null) {
237
+ if (typeof src.usage_window.from !== "string" || !ISO_DATETIME.test(src.usage_window.from)) {
238
+ issues.push({
239
+ severity: "warning",
240
+ code: "invalid_timestamp",
241
+ path,
242
+ message: "sources[].usage_window.from must be an ISO 8601 string with explicit UTC offset",
243
+ });
244
+ }
245
+ }
246
+ if ("to" in src.usage_window && src.usage_window.to != null) {
247
+ if (typeof src.usage_window.to !== "string" || !ISO_DATETIME.test(src.usage_window.to)) {
248
+ issues.push({
249
+ severity: "warning",
250
+ code: "invalid_timestamp",
251
+ path,
252
+ message: "sources[].usage_window.to must be an ISO 8601 string with explicit UTC offset",
253
+ });
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+ }
260
+ // Footnote check
261
+ if (sourceIds.size > 0) {
262
+ const footnotes = extractFootnoteLabels(body);
263
+ for (const label of footnotes) {
264
+ if (!sourceIds.has(label)) {
265
+ issues.push({
266
+ severity: "warning",
267
+ code: "unknown_footnote_source",
268
+ path,
269
+ message: `footnote [^${label}] does not match any source id`,
270
+ });
271
+ }
272
+ }
273
+ }
274
+ // Parameters validation
275
+ if ("parameters" in data && data.parameters != null) {
276
+ if (!Array.isArray(data.parameters)) {
277
+ issues.push({
278
+ severity: "warning",
279
+ code: "invalid_parameters",
280
+ path,
281
+ message: "parameters must be a list",
282
+ });
283
+ }
284
+ else {
285
+ for (const p of data.parameters) {
286
+ if (!p || typeof p !== "object" || !("name" in p) || typeof p.name !== "string" || p.name.trim().length === 0) {
287
+ issues.push({
288
+ severity: "warning",
289
+ code: "invalid_parameters",
290
+ path,
291
+ message: "parameter entry must have a non-empty string name",
292
+ });
293
+ }
294
+ }
295
+ }
296
+ }
297
+ // Attested Computation validation
298
+ if (data.type === "Attested Computation") {
299
+ if (!("runtime" in data) || typeof data.runtime !== "string" || data.runtime.trim().length === 0) {
300
+ issues.push({
301
+ severity: "warning",
302
+ code: "computation_missing_runtime",
303
+ path,
304
+ message: 'Attested Computation requires a non-empty "runtime" field',
305
+ });
306
+ }
307
+ const blocks = computationBlocks(body);
308
+ const hasComputationPath = "computation" in data && typeof data.computation === "string" && data.computation.trim().length > 0;
309
+ if (!hasComputationPath && blocks !== 1) {
310
+ issues.push({
311
+ severity: "warning",
312
+ code: "computation_ambiguous",
313
+ path,
314
+ message: "Attested Computation without computation path must have exactly one code block under # Computation",
315
+ });
316
+ }
317
+ else if (hasComputationPath && blocks >= 1) {
318
+ issues.push({
319
+ severity: "warning",
320
+ code: "computation_ambiguous",
321
+ path,
322
+ message: "Attested Computation with computation path must not have code blocks under # Computation",
323
+ });
324
+ }
325
+ if (hasComputationPath && typeof data.computation === "string") {
326
+ const isUrl = /^[a-z][a-z0-9+.-]*:/i.test(data.computation);
327
+ if (!isUrl) {
328
+ const compPath = data.computation.startsWith("/")
329
+ ? data.computation.slice(1)
330
+ : posix.normalize(posix.join(posix.dirname(path), data.computation));
331
+ if (!ctx.fileExists(compPath)) {
332
+ issues.push({
333
+ severity: "warning",
334
+ code: "computation_file_missing",
335
+ path,
336
+ message: `computation file "${data.computation}" not found in bundle`,
337
+ });
338
+ }
339
+ }
340
+ }
341
+ }
342
+ // Info: outbound links
343
+ const links = extractLinks(body, conceptId);
344
+ for (const target of links) {
345
+ if (!ctx.conceptExists(target)) {
346
+ issues.push({
347
+ severity: "info",
348
+ code: "broken_link",
349
+ path,
350
+ message: `link to concept "${target}" does not exist`,
351
+ });
352
+ }
353
+ }
354
+ // Info: stale
355
+ if (isStale(data, ctx.now)) {
356
+ issues.push({
357
+ severity: "info",
358
+ code: "stale",
359
+ path,
360
+ message: "concept is past its stale_after date",
361
+ });
362
+ }
363
+ // Info: legacy timestamp
364
+ if ("timestamp" in data && !("generated" in data)) {
365
+ issues.push({
366
+ severity: "info",
367
+ code: "legacy_timestamp",
368
+ path,
369
+ message: 'concept uses legacy "timestamp" instead of "generated"',
370
+ });
371
+ }
372
+ // Info: legacy citations
373
+ if (hasLegacyCitations(body)) {
374
+ issues.push({
375
+ severity: "info",
376
+ code: "legacy_citations",
377
+ path,
378
+ message: 'concept contains legacy "# Citations" section',
379
+ });
380
+ }
381
+ return issues;
382
+ }
383
+ export function lintIndexFile(path, text, isRoot) {
384
+ const issues = [];
385
+ const split = splitFrontmatter(text);
386
+ let body = text;
387
+ if (split) {
388
+ body = split.body;
389
+ if (!isRoot) {
390
+ issues.push({
391
+ severity: "error",
392
+ code: "index_frontmatter",
393
+ path,
394
+ message: "non-root index.md must not contain frontmatter",
395
+ });
396
+ }
397
+ else {
398
+ const parsed = parseFrontmatter(split.yaml);
399
+ if ("error" in parsed) {
400
+ issues.push({
401
+ severity: "error",
402
+ code: parsed.error,
403
+ path,
404
+ message: parsed.message,
405
+ });
406
+ }
407
+ else {
408
+ const keys = Object.keys(parsed.data);
409
+ for (const k of keys) {
410
+ if (k !== "okf_version") {
411
+ issues.push({
412
+ severity: "error",
413
+ code: "index_frontmatter",
414
+ path,
415
+ message: `root index.md frontmatter must only declare okf_version, found "${k}"`,
416
+ });
417
+ }
418
+ }
419
+ if ("okf_version" in parsed.data) {
420
+ const ver = parsed.data.okf_version;
421
+ if (ver !== "0.2" && ver !== "0.1") {
422
+ issues.push({
423
+ severity: "warning",
424
+ code: "okf_version_unknown",
425
+ path,
426
+ message: `unknown okf_version "${String(ver)}"`,
427
+ });
428
+ }
429
+ }
430
+ }
431
+ }
432
+ }
433
+ // Must have at least one line starting with '# '
434
+ if (!/(?:^|\n)#[ \t]+[^\r\n]+/.test(body)) {
435
+ issues.push({
436
+ severity: "error",
437
+ code: "index_no_sections",
438
+ path,
439
+ message: "index.md must contain at least one section heading",
440
+ });
441
+ }
442
+ return issues;
443
+ }
444
+ export function lintLogFile(path, text) {
445
+ const issues = [];
446
+ const headingRe = /(?:^|\n)##[ \t]+([^\r\n]+)/g;
447
+ for (const match of text.matchAll(headingRe)) {
448
+ const headingText = match[1]?.trim();
449
+ if (!headingText || !/^\d{4}-\d{2}-\d{2}$/.test(headingText)) {
450
+ issues.push({
451
+ severity: "error",
452
+ code: "log_bad_date_heading",
453
+ path,
454
+ message: `invalid date heading "${headingText ?? ""}", expected YYYY-MM-DD`,
455
+ });
456
+ }
457
+ }
458
+ return issues;
459
+ }
460
+ //# sourceMappingURL=lint.js.map
@@ -0,0 +1,74 @@
1
+ function appendMessage(base, message) {
2
+ if (!message) {
3
+ return base;
4
+ }
5
+ const clean = message.replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim().slice(0, 500);
6
+ if (clean.length === 0) {
7
+ return base;
8
+ }
9
+ return `${base} ${clean}`;
10
+ }
11
+ export function logInitializationEntry(actor, message) {
12
+ return appendMessage(`**Initialization**: Created project bundle (by ${actor}).`, message);
13
+ }
14
+ export function logCreationEntry(title, id, actor, message) {
15
+ return appendMessage(`**Creation**: Added [${title}](/${id}.md) (by ${actor}).`, message);
16
+ }
17
+ export function logUpdateEntry(title, id, actor, deprecated, message) {
18
+ if (deprecated) {
19
+ return appendMessage(`**Deprecation**: Deprecated [${title}](/${id}.md) (by ${actor}).`, message);
20
+ }
21
+ return appendMessage(`**Update**: Updated [${title}](/${id}.md) (by ${actor}).`, message);
22
+ }
23
+ export function logVerificationEntry(title, id, actor, message) {
24
+ return appendMessage(`**Verification**: Verified [${title}](/${id}.md) (by ${actor}).`, message);
25
+ }
26
+ export function logDeletionEntry(id, actor, message) {
27
+ return appendMessage(`**Deletion**: Removed \`${id}\` (by ${actor}).`, message);
28
+ }
29
+ export function logFileUpdateEntry(path, actor, message) {
30
+ return appendMessage(`**Update**: Wrote file \`${path}\` (by ${actor}).`, message);
31
+ }
32
+ export function logFileDeletionEntry(path, actor, message) {
33
+ return appendMessage(`**Deletion**: Removed file \`${path}\` (by ${actor}).`, message);
34
+ }
35
+ export function logImportEntry(actor, message) {
36
+ return appendMessage(`**Import**: Imported bundle archive (by ${actor}).`, message);
37
+ }
38
+ export function prependLogEntry(existing, date, // YYYY-MM-DD UTC
39
+ entry) {
40
+ let text = existing;
41
+ if (!text || !/(?:^|\n)#[ \t]/.test(text)) {
42
+ text = "# Update Log\n";
43
+ }
44
+ const headingMatch = /(?:^|\n)(##[ \t]+([^\r\n]+))(?:\r?\n|$)/.exec(text);
45
+ if (headingMatch) {
46
+ const fullMatch = headingMatch[0];
47
+ const headingText = headingMatch[2]?.trim();
48
+ const matchIndex = headingMatch.index;
49
+ if (headingText === date) {
50
+ const insertPos = matchIndex + fullMatch.length;
51
+ return `${text.slice(0, insertPos)}* ${entry}\n${text.slice(insertPos)}`;
52
+ }
53
+ else {
54
+ const isLeadingNewline = fullMatch.startsWith("\n");
55
+ const insertPos = isLeadingNewline ? matchIndex + 1 : matchIndex;
56
+ return `${text.slice(0, insertPos)}## ${date}\n* ${entry}\n\n${text.slice(insertPos)}`;
57
+ }
58
+ }
59
+ else {
60
+ const titleMatch = /(?:^|\n)(#[ \t]+[^\r\n]+)(?:\r?\n|$)/.exec(text);
61
+ if (titleMatch) {
62
+ const insertPos = titleMatch.index + titleMatch[0].length;
63
+ const prefix = text.slice(0, insertPos);
64
+ const suffix = text.slice(insertPos);
65
+ const needsBlank = !prefix.endsWith("\n\n");
66
+ const separator = needsBlank ? (prefix.endsWith("\n") ? "\n" : "\n\n") : "";
67
+ return `${prefix}${separator}## ${date}\n* ${entry}\n${suffix}`;
68
+ }
69
+ else {
70
+ return `# Update Log\n\n## ${date}\n* ${entry}\n`;
71
+ }
72
+ }
73
+ }
74
+ //# sourceMappingURL=log-file.js.map
@@ -0,0 +1,106 @@
1
+ import { posix } from "node:path";
2
+ import { marked } from "marked";
3
+ import { isReservedName } from "./paths.js";
4
+ const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
5
+ const FOOTNOTE_RE = /\[\^([^\]\s]+)\](?!:)/g;
6
+ export function extractLinks(body, conceptId) {
7
+ const tokens = marked.lexer(body);
8
+ const found = [];
9
+ const seen = new Set();
10
+ marked.walkTokens(tokens, (token) => {
11
+ // marked has no footnote support: `[^id]` plus a `[^id]: x.md` definition lexes as a reference link.
12
+ if (token.type !== "link" || token.raw.startsWith("[^")) {
13
+ return;
14
+ }
15
+ let href = token.href;
16
+ try {
17
+ href = decodeURI(href);
18
+ }
19
+ catch {
20
+ // ignore URI decode failures
21
+ }
22
+ const hashIdx = href.indexOf("#");
23
+ if (hashIdx !== -1) {
24
+ href = href.slice(0, hashIdx);
25
+ }
26
+ const queryIdx = href.indexOf("?");
27
+ if (queryIdx !== -1) {
28
+ href = href.slice(0, queryIdx);
29
+ }
30
+ if (!href || href.startsWith("//") || SCHEME_RE.test(href)) {
31
+ return;
32
+ }
33
+ let resolved;
34
+ if (href.startsWith("/")) {
35
+ resolved = posix.normalize(href.slice(1));
36
+ }
37
+ else {
38
+ const dir = posix.dirname(conceptId);
39
+ resolved = posix.normalize(posix.join(dir === "." ? "" : dir, href));
40
+ }
41
+ if (resolved === ".." || resolved.startsWith("../") || href.endsWith("/")) {
42
+ return;
43
+ }
44
+ if (!resolved.endsWith(".md")) {
45
+ return;
46
+ }
47
+ const base = posix.basename(resolved);
48
+ if (isReservedName(base)) {
49
+ return;
50
+ }
51
+ const targetId = resolved.slice(0, -3);
52
+ if (!seen.has(targetId)) {
53
+ seen.add(targetId);
54
+ found.push(targetId);
55
+ }
56
+ });
57
+ return found;
58
+ }
59
+ export function extractFootnoteLabels(body) {
60
+ const tokens = marked.lexer(body);
61
+ const labels = new Set();
62
+ marked.walkTokens(tokens, (token) => {
63
+ if (token.type === "code" || token.type === "codespan") {
64
+ return;
65
+ }
66
+ if ("raw" in token && typeof token.raw === "string") {
67
+ const re = new RegExp(FOOTNOTE_RE.source, "g");
68
+ for (const match of token.raw.matchAll(re)) {
69
+ if (match[1]) {
70
+ labels.add(match[1]);
71
+ }
72
+ }
73
+ }
74
+ });
75
+ return Array.from(labels);
76
+ }
77
+ export function computationBlocks(body) {
78
+ const tokens = marked.lexer(body);
79
+ let inComputation = false;
80
+ let count = 0;
81
+ for (const token of tokens) {
82
+ if (token.type === "heading" && token.depth === 1) {
83
+ if (token.text.trim().toLowerCase() === "computation") {
84
+ inComputation = true;
85
+ continue;
86
+ }
87
+ else if (inComputation) {
88
+ break;
89
+ }
90
+ }
91
+ if (inComputation && token.type === "code") {
92
+ count++;
93
+ }
94
+ }
95
+ return count;
96
+ }
97
+ export function hasLegacyCitations(body) {
98
+ const tokens = marked.lexer(body);
99
+ for (const token of tokens) {
100
+ if (token.type === "heading" && token.depth === 1 && token.text.trim().toLowerCase() === "citations") {
101
+ return true;
102
+ }
103
+ }
104
+ return false;
105
+ }
106
+ //# sourceMappingURL=markdown.js.map