@ferax564/noma-cli 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/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/noma.mjs +8 -0
- package/dist/ast.d.ts +111 -0
- package/dist/ast.js +23 -0
- package/dist/ast.js.map +1 -0
- package/dist/book.d.ts +56 -0
- package/dist/book.js +120 -0
- package/dist/book.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +573 -0
- package/dist/cli.js.map +1 -0
- package/dist/diff.d.ts +29 -0
- package/dist/diff.js +77 -0
- package/dist/diff.js.map +1 -0
- package/dist/fmt.d.ts +1 -0
- package/dist/fmt.js +105 -0
- package/dist/fmt.js.map +1 -0
- package/dist/ids.d.ts +15 -0
- package/dist/ids.js +27 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/inline.d.ts +14 -0
- package/dist/inline.js +83 -0
- package/dist/inline.js.map +1 -0
- package/dist/loader.d.ts +12 -0
- package/dist/loader.js +59 -0
- package/dist/loader.js.map +1 -0
- package/dist/parser.d.ts +7 -0
- package/dist/parser.js +434 -0
- package/dist/parser.js.map +1 -0
- package/dist/patch.d.ts +61 -0
- package/dist/patch.js +530 -0
- package/dist/patch.js.map +1 -0
- package/dist/renderer-html.d.ts +44 -0
- package/dist/renderer-html.js +929 -0
- package/dist/renderer-html.js.map +1 -0
- package/dist/renderer-json.d.ts +5 -0
- package/dist/renderer-json.js +4 -0
- package/dist/renderer-json.js.map +1 -0
- package/dist/renderer-llm.d.ts +29 -0
- package/dist/renderer-llm.js +275 -0
- package/dist/renderer-llm.js.map +1 -0
- package/dist/renderer-noma.d.ts +10 -0
- package/dist/renderer-noma.js +179 -0
- package/dist/renderer-noma.js.map +1 -0
- package/dist/renderer-site.d.ts +11 -0
- package/dist/renderer-site.js +175 -0
- package/dist/renderer-site.js.map +1 -0
- package/dist/validator.d.ts +24 -0
- package/dist/validator.js +699 -0
- package/dist/validator.js.map +1 -0
- package/dist/verify.d.ts +10 -0
- package/dist/verify.js +141 -0
- package/dist/verify.js.map +1 -0
- package/package.json +83 -0
- package/schemas/ast.schema.json +187 -0
- package/schemas/capability.schema.json +70 -0
- package/schemas/patch-op.schema.json +92 -0
- package/schemas/patch-transaction.schema.json +28 -0
- package/schemas/transcript.schema.json +95 -0
- package/src/ast.ts +152 -0
- package/src/book.ts +162 -0
- package/src/cli.ts +595 -0
- package/src/diff.ts +108 -0
- package/src/fmt.ts +126 -0
- package/src/ids.ts +42 -0
- package/src/index.ts +20 -0
- package/src/inline.ts +92 -0
- package/src/loader.ts +55 -0
- package/src/parser.ts +501 -0
- package/src/patch.ts +646 -0
- package/src/renderer-html.ts +1047 -0
- package/src/renderer-json.ts +9 -0
- package/src/renderer-llm.ts +320 -0
- package/src/renderer-noma.ts +220 -0
- package/src/renderer-site.ts +245 -0
- package/src/validator.ts +733 -0
- package/src/verify.ts +157 -0
- package/themes/dark.css +382 -0
- package/themes/default.css +537 -0
package/src/validator.ts
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
import yaml from "js-yaml";
|
|
2
|
+
import type { Diagnostic, DirectiveNode, DocumentNode, Node } from "./ast.js";
|
|
3
|
+
import { walk } from "./ast.js";
|
|
4
|
+
|
|
5
|
+
export interface ValidateOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Disable claim-without-evidence warnings entirely (default: enabled).
|
|
8
|
+
* Per-block opt-out: add the `noverify` flag attribute to the claim.
|
|
9
|
+
*/
|
|
10
|
+
requireEvidenceForClaims?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Reference time for `stale-citation` checks. Defaults to `new Date()`.
|
|
13
|
+
* Tests pass a fixed clock for determinism.
|
|
14
|
+
*/
|
|
15
|
+
now?: Date;
|
|
16
|
+
/** Citations older than this many days are flagged stale. Default: 365. */
|
|
17
|
+
staleCitationDays?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Rule codes to drop from the result. Diagnostics whose `code` matches an
|
|
20
|
+
* entry are filtered out and do not affect exit status. Mirrors the per-block
|
|
21
|
+
* `noverify` flag at file level. Used by `noma check --ignore-rule X`.
|
|
22
|
+
*/
|
|
23
|
+
ignoreRules?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_STALE_DAYS = 365;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Directive allow-lists per declared `profile`. A document that opts in to a
|
|
30
|
+
* profile guarantees to downstream tools that it only uses these directive
|
|
31
|
+
* names. The validator warns on out-of-profile directives so authors notice
|
|
32
|
+
* before their consumers do.
|
|
33
|
+
*/
|
|
34
|
+
const PROFILES: Record<string, ReadonlySet<string>> = {
|
|
35
|
+
minimal: new Set([
|
|
36
|
+
"summary",
|
|
37
|
+
"abstract",
|
|
38
|
+
"callout",
|
|
39
|
+
"note",
|
|
40
|
+
"warning",
|
|
41
|
+
"tip",
|
|
42
|
+
"figure",
|
|
43
|
+
"citation",
|
|
44
|
+
"math",
|
|
45
|
+
"table",
|
|
46
|
+
]),
|
|
47
|
+
technical: new Set([
|
|
48
|
+
"summary",
|
|
49
|
+
"abstract",
|
|
50
|
+
"callout",
|
|
51
|
+
"note",
|
|
52
|
+
"warning",
|
|
53
|
+
"tip",
|
|
54
|
+
"hero",
|
|
55
|
+
"grid",
|
|
56
|
+
"card",
|
|
57
|
+
"columns",
|
|
58
|
+
"tabs",
|
|
59
|
+
"accordion",
|
|
60
|
+
"sidebar",
|
|
61
|
+
"button",
|
|
62
|
+
"figure",
|
|
63
|
+
"plot",
|
|
64
|
+
"plotly",
|
|
65
|
+
"diagram",
|
|
66
|
+
"dataset",
|
|
67
|
+
"code_cell",
|
|
68
|
+
"output",
|
|
69
|
+
"control",
|
|
70
|
+
"export_button",
|
|
71
|
+
"agent_task",
|
|
72
|
+
"todo",
|
|
73
|
+
"citation",
|
|
74
|
+
"math",
|
|
75
|
+
"table",
|
|
76
|
+
"html",
|
|
77
|
+
"svg",
|
|
78
|
+
"script",
|
|
79
|
+
]),
|
|
80
|
+
research: new Set([
|
|
81
|
+
"summary",
|
|
82
|
+
"abstract",
|
|
83
|
+
"callout",
|
|
84
|
+
"note",
|
|
85
|
+
"warning",
|
|
86
|
+
"tip",
|
|
87
|
+
"claim",
|
|
88
|
+
"evidence",
|
|
89
|
+
"counterevidence",
|
|
90
|
+
"assumption",
|
|
91
|
+
"risk",
|
|
92
|
+
"hypothesis",
|
|
93
|
+
"result",
|
|
94
|
+
"limitation",
|
|
95
|
+
"open_question",
|
|
96
|
+
"decision",
|
|
97
|
+
"adr",
|
|
98
|
+
"dataset",
|
|
99
|
+
"plot",
|
|
100
|
+
"plotly",
|
|
101
|
+
"diagram",
|
|
102
|
+
"metric",
|
|
103
|
+
"figure",
|
|
104
|
+
"agent_task",
|
|
105
|
+
"todo",
|
|
106
|
+
"review",
|
|
107
|
+
"comment",
|
|
108
|
+
"change_request",
|
|
109
|
+
"provenance",
|
|
110
|
+
"confidence",
|
|
111
|
+
"citation",
|
|
112
|
+
"state_change",
|
|
113
|
+
"math",
|
|
114
|
+
"table",
|
|
115
|
+
]),
|
|
116
|
+
memory: new Set(["memory", "memory_index"]),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const MEMORY_TYPES = new Set(["user", "feedback", "project", "reference"]);
|
|
120
|
+
const ISO_DATE_RE =
|
|
121
|
+
/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/;
|
|
122
|
+
|
|
123
|
+
export const KNOWN_PROFILES = Object.keys(PROFILES);
|
|
124
|
+
|
|
125
|
+
export function validate(doc: DocumentNode, options: ValidateOptions = {}): Diagnostic[] {
|
|
126
|
+
const requireEvidence = options.requireEvidenceForClaims !== false;
|
|
127
|
+
const metaStale = readPositiveNumber(doc.meta.stale_citation_days);
|
|
128
|
+
const staleDays =
|
|
129
|
+
options.staleCitationDays ?? metaStale ?? DEFAULT_STALE_DAYS;
|
|
130
|
+
const now = options.now ?? new Date();
|
|
131
|
+
|
|
132
|
+
const diagnostics: Diagnostic[] = [];
|
|
133
|
+
const ids = new Map<string, Node>();
|
|
134
|
+
const aliasIds = new Set<string>();
|
|
135
|
+
const claims: DirectiveNode[] = [];
|
|
136
|
+
const evidenceTargets = new Set<string>();
|
|
137
|
+
const referenced = new Set<string>();
|
|
138
|
+
const datasetIds = new Map<string, DirectiveNode>();
|
|
139
|
+
const datasetColumns = new Map<string, Set<string>>();
|
|
140
|
+
|
|
141
|
+
const aliasToNode = new Map<string, Node>();
|
|
142
|
+
const declaredProfiles = readDeclaredProfiles(doc.meta);
|
|
143
|
+
const profileSet: Set<string> | undefined = (() => {
|
|
144
|
+
if (declaredProfiles.length === 0) return undefined;
|
|
145
|
+
const union = new Set<string>();
|
|
146
|
+
let any = false;
|
|
147
|
+
for (const name of declaredProfiles) {
|
|
148
|
+
const set = PROFILES[name];
|
|
149
|
+
if (!set) {
|
|
150
|
+
diagnostics.push({
|
|
151
|
+
severity: "warning",
|
|
152
|
+
code: "unknown-profile",
|
|
153
|
+
message: `Document declares unknown profile "${name}". Known: ${KNOWN_PROFILES.join(", ")}.`,
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
any = true;
|
|
158
|
+
for (const directive of set) union.add(directive);
|
|
159
|
+
}
|
|
160
|
+
return any ? union : undefined;
|
|
161
|
+
})();
|
|
162
|
+
const profileLabel = declaredProfiles.join("+");
|
|
163
|
+
|
|
164
|
+
const wikilinkRe = /\[\[([a-zA-Z_][\w\-./:]*)\]\]/g;
|
|
165
|
+
const wikilinkRefs = new Set<string>();
|
|
166
|
+
const collectWikilinks = (text: string): void => {
|
|
167
|
+
const stripped = text.replace(/`[^`]*`/g, "");
|
|
168
|
+
for (const m of stripped.matchAll(wikilinkRe)) {
|
|
169
|
+
referenced.add(m[1]!);
|
|
170
|
+
wikilinkRefs.add(m[1]!);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
for (const node of walk(doc)) {
|
|
175
|
+
if (node.type === "paragraph" || node.type === "quote") collectWikilinks(node.content);
|
|
176
|
+
else if (node.type === "list_item") collectWikilinks(node.content);
|
|
177
|
+
else if (node.type === "section") collectWikilinks(node.title);
|
|
178
|
+
else if (node.type === "directive" && node.body) collectWikilinks(node.body);
|
|
179
|
+
else if (node.type === "table") {
|
|
180
|
+
for (const cell of node.header) collectWikilinks(cell);
|
|
181
|
+
for (const row of node.rows) for (const cell of row) collectWikilinks(cell);
|
|
182
|
+
}
|
|
183
|
+
if (node.id) {
|
|
184
|
+
if (ids.has(node.id)) {
|
|
185
|
+
diagnostics.push({
|
|
186
|
+
severity: "error",
|
|
187
|
+
code: "duplicate-id",
|
|
188
|
+
message: `Duplicate block ID "${node.id}".`,
|
|
189
|
+
pos: node.pos,
|
|
190
|
+
nodeId: node.id,
|
|
191
|
+
});
|
|
192
|
+
} else {
|
|
193
|
+
ids.set(node.id, node);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (node.aliases) {
|
|
197
|
+
for (const a of node.aliases) {
|
|
198
|
+
aliasIds.add(a);
|
|
199
|
+
if (!aliasToNode.has(a)) aliasToNode.set(a, node);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (node.type !== "directive") continue;
|
|
204
|
+
|
|
205
|
+
if (profileSet && !suppressed(node) && !profileSet.has(node.name)) {
|
|
206
|
+
diagnostics.push({
|
|
207
|
+
severity: "warning",
|
|
208
|
+
code: "out-of-profile-directive",
|
|
209
|
+
message: `Directive "${node.name}" is not part of the declared "${profileLabel}" profile.`,
|
|
210
|
+
pos: node.pos,
|
|
211
|
+
nodeId: node.id,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (node.name === "dataset") {
|
|
216
|
+
if (node.id) {
|
|
217
|
+
datasetIds.set(node.id, node);
|
|
218
|
+
datasetColumns.set(node.id, readDatasetColumns(node));
|
|
219
|
+
}
|
|
220
|
+
if (
|
|
221
|
+
!suppressed(node) &&
|
|
222
|
+
typeof node.attrs.src === "string" &&
|
|
223
|
+
(!(node.body && node.body.trim()) || node.attrs.format === "error")
|
|
224
|
+
) {
|
|
225
|
+
diagnostics.push({
|
|
226
|
+
severity: "warning",
|
|
227
|
+
code: "dataset-src-missing",
|
|
228
|
+
message: `Dataset "${node.id ?? "?"}" src="${node.attrs.src}" failed to load (file missing or unreadable).`,
|
|
229
|
+
pos: node.pos,
|
|
230
|
+
nodeId: node.id,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (node.name === "claim" && node.id) claims.push(node);
|
|
236
|
+
|
|
237
|
+
if (node.name === "state_change" && !suppressed(node)) {
|
|
238
|
+
const block = node.attrs.block;
|
|
239
|
+
if (typeof block === "string") {
|
|
240
|
+
referenced.add(block);
|
|
241
|
+
} else {
|
|
242
|
+
diagnostics.push({
|
|
243
|
+
severity: "warning",
|
|
244
|
+
code: "state-change-missing-block",
|
|
245
|
+
message: `state_change has no \`block=\` attribute pointing at the changed block.`,
|
|
246
|
+
pos: node.pos,
|
|
247
|
+
nodeId: node.id,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const hasFrom = "from" in node.attrs;
|
|
251
|
+
const hasTo = "to" in node.attrs;
|
|
252
|
+
if (!hasFrom || !hasTo) {
|
|
253
|
+
diagnostics.push({
|
|
254
|
+
severity: "warning",
|
|
255
|
+
code: "state-change-missing-from-to",
|
|
256
|
+
message: `state_change "${node.id ?? "?"}" needs both \`from=\` and \`to=\` attributes.`,
|
|
257
|
+
pos: node.pos,
|
|
258
|
+
nodeId: node.id,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (node.name === "evidence" || node.name === "counterevidence") {
|
|
264
|
+
const target = node.attrs.for;
|
|
265
|
+
if (typeof target === "string") {
|
|
266
|
+
referenced.add(target);
|
|
267
|
+
evidenceTargets.add(target);
|
|
268
|
+
} else {
|
|
269
|
+
diagnostics.push({
|
|
270
|
+
severity: "warning",
|
|
271
|
+
code: "evidence-missing-for",
|
|
272
|
+
message: `${node.name} block has no \`for=\` attribute.`,
|
|
273
|
+
pos: node.pos,
|
|
274
|
+
nodeId: node.id,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (node.name === "diagram" && !suppressed(node)) {
|
|
280
|
+
const kind = String(node.attrs.kind ?? "");
|
|
281
|
+
if (!kind) {
|
|
282
|
+
diagnostics.push({
|
|
283
|
+
severity: "warning",
|
|
284
|
+
code: "diagram-missing-kind",
|
|
285
|
+
message: `Diagram "${node.id ?? "?"}" has no \`kind=\` (mermaid|graphviz|drawio).`,
|
|
286
|
+
pos: node.pos,
|
|
287
|
+
nodeId: node.id,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
if (!(node.body && node.body.trim())) {
|
|
291
|
+
diagnostics.push({
|
|
292
|
+
severity: "warning",
|
|
293
|
+
code: "diagram-missing-source",
|
|
294
|
+
message: `Diagram "${node.id ?? "?"}" has no source body.`,
|
|
295
|
+
pos: node.pos,
|
|
296
|
+
nodeId: node.id,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (node.name === "plotly" && !suppressed(node)) {
|
|
302
|
+
const body = (node.body ?? "").trim();
|
|
303
|
+
if (!body) {
|
|
304
|
+
diagnostics.push({
|
|
305
|
+
severity: "warning",
|
|
306
|
+
code: "plotly-missing-spec",
|
|
307
|
+
message: `Plotly "${node.id ?? "?"}" has no JSON spec body.`,
|
|
308
|
+
pos: node.pos,
|
|
309
|
+
nodeId: node.id,
|
|
310
|
+
});
|
|
311
|
+
} else {
|
|
312
|
+
try {
|
|
313
|
+
JSON.parse(body);
|
|
314
|
+
} catch (e) {
|
|
315
|
+
diagnostics.push({
|
|
316
|
+
severity: "error",
|
|
317
|
+
code: "plotly-invalid-json",
|
|
318
|
+
message: `Plotly "${node.id ?? "?"}" body is not valid JSON: ${(e as Error).message}`,
|
|
319
|
+
pos: node.pos,
|
|
320
|
+
nodeId: node.id,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (node.name === "figure" && !node.attrs.alt && !node.attrs.caption) {
|
|
327
|
+
diagnostics.push({
|
|
328
|
+
severity: "warning",
|
|
329
|
+
code: "figure-missing-alt",
|
|
330
|
+
message: `Figure block has no alt or caption text.`,
|
|
331
|
+
pos: node.pos,
|
|
332
|
+
nodeId: node.id,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (node.name === "plot") {
|
|
337
|
+
const hasData = "data" in node.attrs || "dataset" in node.attrs;
|
|
338
|
+
if (typeof node.attrs.dataset === "string") {
|
|
339
|
+
const ref = node.attrs.dataset;
|
|
340
|
+
if (!datasetIds.has(ref)) {
|
|
341
|
+
diagnostics.push({
|
|
342
|
+
severity: "error",
|
|
343
|
+
code: "plot-unknown-dataset",
|
|
344
|
+
message: `Plot "${node.id ?? "?"}" references unknown dataset "${ref}".`,
|
|
345
|
+
pos: node.pos,
|
|
346
|
+
nodeId: node.id,
|
|
347
|
+
});
|
|
348
|
+
} else if (typeof node.attrs.column === "string") {
|
|
349
|
+
const cols = datasetColumns.get(ref) ?? new Set<string>();
|
|
350
|
+
if (cols.size > 0 && !cols.has(node.attrs.column)) {
|
|
351
|
+
diagnostics.push({
|
|
352
|
+
severity: "error",
|
|
353
|
+
code: "plot-unknown-column",
|
|
354
|
+
message: `Plot "${node.id ?? "?"}" references unknown column "${node.attrs.column}" in dataset "${ref}".`,
|
|
355
|
+
pos: node.pos,
|
|
356
|
+
nodeId: node.id,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (!hasData) {
|
|
362
|
+
diagnostics.push({
|
|
363
|
+
severity: "error",
|
|
364
|
+
code: "plot-missing-data",
|
|
365
|
+
message: `Plot has no data or dataset attribute.`,
|
|
366
|
+
pos: node.pos,
|
|
367
|
+
nodeId: node.id,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
if (!suppressed(node)) {
|
|
371
|
+
const data = typeof node.attrs.data === "string" ? node.attrs.data : "";
|
|
372
|
+
const labels = typeof node.attrs.xlabels === "string" ? node.attrs.xlabels : "";
|
|
373
|
+
const delim = (s: string): "comma" | "space" | null => {
|
|
374
|
+
const hasComma = /,/.test(s);
|
|
375
|
+
const hasSpace = /\s/.test(s.trim());
|
|
376
|
+
if (hasComma && !hasSpace) return "comma";
|
|
377
|
+
if (hasSpace && !hasComma) return "space";
|
|
378
|
+
return null;
|
|
379
|
+
};
|
|
380
|
+
const a = delim(data);
|
|
381
|
+
const b = delim(labels);
|
|
382
|
+
if (a && b && a !== b) {
|
|
383
|
+
diagnostics.push({
|
|
384
|
+
severity: "warning",
|
|
385
|
+
code: "plot-mixed-delimiters",
|
|
386
|
+
message: `Plot "${node.id ?? "?"}" mixes ${a}-separated data with ${b}-separated xlabels. Use commas for both (preferred).`,
|
|
387
|
+
pos: node.pos,
|
|
388
|
+
nodeId: node.id,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (node.name === "risk" && !suppressed(node) && !node.attrs.owner) {
|
|
395
|
+
diagnostics.push({
|
|
396
|
+
severity: "warning",
|
|
397
|
+
code: "risk-without-owner",
|
|
398
|
+
message: `Risk "${node.id ?? "?"}" has no \`owner=\` attribute.`,
|
|
399
|
+
pos: node.pos,
|
|
400
|
+
nodeId: node.id,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (
|
|
405
|
+
(node.name === "decision" || node.name === "adr") &&
|
|
406
|
+
!suppressed(node) &&
|
|
407
|
+
!node.attrs.status
|
|
408
|
+
) {
|
|
409
|
+
diagnostics.push({
|
|
410
|
+
severity: "warning",
|
|
411
|
+
code: "decision-without-status",
|
|
412
|
+
message: `${node.name} "${node.id ?? "?"}" has no \`status=\` attribute.`,
|
|
413
|
+
pos: node.pos,
|
|
414
|
+
nodeId: node.id,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (
|
|
419
|
+
(node.name === "agent_task" || node.name === "todo") &&
|
|
420
|
+
!suppressed(node) &&
|
|
421
|
+
!node.attrs.scope &&
|
|
422
|
+
!(node.body && node.body.trim().length > 0) &&
|
|
423
|
+
node.children.length === 0
|
|
424
|
+
) {
|
|
425
|
+
diagnostics.push({
|
|
426
|
+
severity: "warning",
|
|
427
|
+
code: "agent-task-without-scope",
|
|
428
|
+
message: `Agent task "${node.id ?? "?"}" has no scope or body.`,
|
|
429
|
+
pos: node.pos,
|
|
430
|
+
nodeId: node.id,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (
|
|
435
|
+
(node.name === "html" || node.name === "svg" || node.name === "script") &&
|
|
436
|
+
!suppressed(node) &&
|
|
437
|
+
node.attrs.trusted !== true
|
|
438
|
+
) {
|
|
439
|
+
diagnostics.push({
|
|
440
|
+
severity: "warning",
|
|
441
|
+
code: "escape-hatch-untrusted",
|
|
442
|
+
message: `${node.name} escape-hatch block has no \`trusted\` attribute. Add \`trusted\` to silence this warning, or \`noverify\` to suppress all checks on this block.`,
|
|
443
|
+
pos: node.pos,
|
|
444
|
+
nodeId: node.id,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (node.name === "memory" && !suppressed(node)) {
|
|
449
|
+
const t = node.attrs.type;
|
|
450
|
+
if (typeof t !== "string" || !t) {
|
|
451
|
+
diagnostics.push({
|
|
452
|
+
severity: "error",
|
|
453
|
+
code: "memory-missing-type",
|
|
454
|
+
message: `Memory "${node.id ?? "?"}" has no \`type=\` attribute.`,
|
|
455
|
+
pos: node.pos,
|
|
456
|
+
nodeId: node.id,
|
|
457
|
+
});
|
|
458
|
+
} else if (!MEMORY_TYPES.has(t)) {
|
|
459
|
+
diagnostics.push({
|
|
460
|
+
severity: "error",
|
|
461
|
+
code: "memory-invalid-type",
|
|
462
|
+
message: `Memory "${node.id ?? "?"}" has type="${t}". Must be one of: ${[...MEMORY_TYPES].join(", ")}.`,
|
|
463
|
+
pos: node.pos,
|
|
464
|
+
nodeId: node.id,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
if ("confidence" in node.attrs) {
|
|
468
|
+
const c = node.attrs.confidence;
|
|
469
|
+
let num: number | null = null;
|
|
470
|
+
if (typeof c === "number" && Number.isFinite(c)) {
|
|
471
|
+
num = c;
|
|
472
|
+
} else if (typeof c === "string" && c.trim() !== "") {
|
|
473
|
+
const n = Number(c);
|
|
474
|
+
if (Number.isFinite(n)) num = n;
|
|
475
|
+
}
|
|
476
|
+
if (num === null || num < 0 || num > 1) {
|
|
477
|
+
diagnostics.push({
|
|
478
|
+
severity: "error",
|
|
479
|
+
code: "memory-invalid-confidence",
|
|
480
|
+
message: `Memory "${node.id ?? "?"}" confidence="${c}" must be a number in [0, 1].`,
|
|
481
|
+
pos: node.pos,
|
|
482
|
+
nodeId: node.id,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if ("last_seen" in node.attrs) {
|
|
487
|
+
const ls = node.attrs.last_seen;
|
|
488
|
+
const s = typeof ls === "string" ? ls : "";
|
|
489
|
+
if (!s || !ISO_DATE_RE.test(s) || !isValidIsoDate(s)) {
|
|
490
|
+
diagnostics.push({
|
|
491
|
+
severity: "error",
|
|
492
|
+
code: "memory-invalid-last-seen",
|
|
493
|
+
message: `Memory "${node.id ?? "?"}" last_seen="${ls}" must be ISO date (YYYY-MM-DD or full ISO 8601).`,
|
|
494
|
+
pos: node.pos,
|
|
495
|
+
nodeId: node.id,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (!node.id) {
|
|
500
|
+
diagnostics.push({
|
|
501
|
+
severity: "error",
|
|
502
|
+
code: "memory-missing-id",
|
|
503
|
+
message: `Memory block has no \`id=\` attribute.`,
|
|
504
|
+
pos: node.pos,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (node.name === "citation" && !suppressed(node) && node.attrs.accessed) {
|
|
510
|
+
const perBlock = readPositiveNumber(node.attrs.stale_after_days);
|
|
511
|
+
const window = perBlock ?? staleDays;
|
|
512
|
+
const stale = isStale(String(node.attrs.accessed), now, window);
|
|
513
|
+
if (stale) {
|
|
514
|
+
diagnostics.push({
|
|
515
|
+
severity: "warning",
|
|
516
|
+
code: "stale-citation",
|
|
517
|
+
message: `Citation "${node.id ?? "?"}" was last accessed ${node.attrs.accessed} (>${window} days ago).`,
|
|
518
|
+
pos: node.pos,
|
|
519
|
+
nodeId: node.id,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
for (const target of referenced) {
|
|
526
|
+
if (!ids.has(target) && !aliasIds.has(target)) {
|
|
527
|
+
diagnostics.push({
|
|
528
|
+
severity: "error",
|
|
529
|
+
code: "broken-reference",
|
|
530
|
+
message: `Reference to unknown block ID "${target}".`,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (declaredProfiles.includes("memory")) {
|
|
536
|
+
for (const target of wikilinkRefs) {
|
|
537
|
+
const node = ids.get(target) ?? aliasToNode.get(target);
|
|
538
|
+
if (!node) continue;
|
|
539
|
+
const isMemory =
|
|
540
|
+
node.type === "directive" && (node as DirectiveNode).name === "memory";
|
|
541
|
+
if (!isMemory) {
|
|
542
|
+
diagnostics.push({
|
|
543
|
+
severity: "warning",
|
|
544
|
+
code: "memory-wikilink-non-memory-target",
|
|
545
|
+
message: `Wikilink [[${target}]] points at a non-::memory block. Memory profile expects wikilinks to resolve to ::memory directives.`,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (requireEvidence) {
|
|
552
|
+
for (const claim of claims) {
|
|
553
|
+
if (suppressed(claim)) continue;
|
|
554
|
+
if (claim.id && !evidenceTargets.has(claim.id)) {
|
|
555
|
+
diagnostics.push({
|
|
556
|
+
severity: "warning",
|
|
557
|
+
code: "claim-without-evidence",
|
|
558
|
+
message: `Claim "${claim.id}" has no evidence backing it.`,
|
|
559
|
+
pos: claim.pos,
|
|
560
|
+
nodeId: claim.id,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const ignore = options.ignoreRules;
|
|
567
|
+
if (ignore && ignore.length > 0) {
|
|
568
|
+
const known = collectRuleCodes();
|
|
569
|
+
for (const rule of ignore) {
|
|
570
|
+
if (!known.has(rule)) {
|
|
571
|
+
diagnostics.push({
|
|
572
|
+
severity: "info",
|
|
573
|
+
code: "unknown-ignore-rule",
|
|
574
|
+
message: `--ignore-rule "${rule}" matches no known validator rule (ignored).`,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const set = new Set(ignore);
|
|
579
|
+
return diagnostics.filter((d) => !set.has(d.code));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return diagnostics;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function readDeclaredProfiles(meta: Record<string, unknown>): string[] {
|
|
586
|
+
if (Array.isArray(meta.profiles)) {
|
|
587
|
+
const out: string[] = [];
|
|
588
|
+
for (const p of meta.profiles) {
|
|
589
|
+
if (typeof p === "string" && p.trim()) out.push(p.trim());
|
|
590
|
+
}
|
|
591
|
+
if (out.length > 0) return out;
|
|
592
|
+
}
|
|
593
|
+
if (typeof meta.profile === "string" && meta.profile.trim()) {
|
|
594
|
+
return [meta.profile.trim()];
|
|
595
|
+
}
|
|
596
|
+
return [];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const KNOWN_RULES = [
|
|
600
|
+
"duplicate-id",
|
|
601
|
+
"out-of-profile-directive",
|
|
602
|
+
"unknown-profile",
|
|
603
|
+
"broken-reference",
|
|
604
|
+
"evidence-missing-for",
|
|
605
|
+
"figure-missing-alt",
|
|
606
|
+
"plot-unknown-dataset",
|
|
607
|
+
"plot-unknown-column",
|
|
608
|
+
"plot-missing-data",
|
|
609
|
+
"plot-mixed-delimiters",
|
|
610
|
+
"risk-without-owner",
|
|
611
|
+
"decision-without-status",
|
|
612
|
+
"agent-task-without-scope",
|
|
613
|
+
"escape-hatch-untrusted",
|
|
614
|
+
"stale-citation",
|
|
615
|
+
"claim-without-evidence",
|
|
616
|
+
"state-change-missing-block",
|
|
617
|
+
"state-change-missing-from-to",
|
|
618
|
+
"diagram-missing-kind",
|
|
619
|
+
"diagram-missing-source",
|
|
620
|
+
"plotly-missing-spec",
|
|
621
|
+
"plotly-invalid-json",
|
|
622
|
+
"dataset-src-missing",
|
|
623
|
+
"memory-missing-type",
|
|
624
|
+
"memory-invalid-type",
|
|
625
|
+
"memory-invalid-confidence",
|
|
626
|
+
"memory-invalid-last-seen",
|
|
627
|
+
"memory-missing-id",
|
|
628
|
+
"memory-wikilink-non-memory-target",
|
|
629
|
+
];
|
|
630
|
+
|
|
631
|
+
function collectRuleCodes(): Set<string> {
|
|
632
|
+
return new Set(KNOWN_RULES);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function suppressed(node: DirectiveNode): boolean {
|
|
636
|
+
return node.attrs.noverify === true;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function readDatasetColumns(node: DirectiveNode): Set<string> {
|
|
640
|
+
const cols = new Set<string>();
|
|
641
|
+
if (typeof node.attrs.columns === "string") {
|
|
642
|
+
for (const c of node.attrs.columns.split(/[,\s]+/).filter(Boolean)) cols.add(c);
|
|
643
|
+
}
|
|
644
|
+
const body = node.body ?? "";
|
|
645
|
+
const format = String(node.attrs.format ?? "").toLowerCase();
|
|
646
|
+
if (!body.trim()) return cols;
|
|
647
|
+
|
|
648
|
+
if (format === "csv" || format === "tsv") {
|
|
649
|
+
const delim = format === "tsv" ? "\t" : ",";
|
|
650
|
+
const firstLine = body.replace(/\r\n?/g, "\n").split("\n").find((l) => l.length > 0);
|
|
651
|
+
if (firstLine) {
|
|
652
|
+
for (const c of firstLine.split(delim).map((s) => s.trim()).filter(Boolean)) {
|
|
653
|
+
cols.add(c);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return cols;
|
|
657
|
+
}
|
|
658
|
+
if (format === "json") {
|
|
659
|
+
try {
|
|
660
|
+
const parsed = JSON.parse(body);
|
|
661
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
662
|
+
const head = parsed[0];
|
|
663
|
+
if (head && typeof head === "object" && !Array.isArray(head)) {
|
|
664
|
+
for (const k of Object.keys(head as Record<string, unknown>)) cols.add(k);
|
|
665
|
+
}
|
|
666
|
+
} else if (parsed && typeof parsed === "object" && Array.isArray((parsed as Record<string, unknown>).columns)) {
|
|
667
|
+
for (const c of (parsed as { columns: unknown[] }).columns) {
|
|
668
|
+
if (typeof c === "string") cols.add(c);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
} catch {
|
|
672
|
+
// fall through
|
|
673
|
+
}
|
|
674
|
+
return cols;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Default: YAML (existing behavior).
|
|
678
|
+
let parsed: unknown;
|
|
679
|
+
try {
|
|
680
|
+
parsed = yaml.load(body);
|
|
681
|
+
} catch {
|
|
682
|
+
parsed = null;
|
|
683
|
+
}
|
|
684
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
685
|
+
const schema = (parsed as Record<string, unknown>).schema;
|
|
686
|
+
if (schema && typeof schema === "object" && !Array.isArray(schema)) {
|
|
687
|
+
for (const k of Object.keys(schema as Record<string, unknown>)) cols.add(k);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return cols;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function readPositiveNumber(v: unknown): number | undefined {
|
|
694
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
|
|
695
|
+
if (typeof v === "string") {
|
|
696
|
+
const n = Number(v);
|
|
697
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
698
|
+
}
|
|
699
|
+
return undefined;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function isValidIsoDate(s: string): boolean {
|
|
703
|
+
const t = Date.parse(s);
|
|
704
|
+
if (Number.isNaN(t)) return false;
|
|
705
|
+
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
706
|
+
if (!m) return true;
|
|
707
|
+
const y = Number(m[1]);
|
|
708
|
+
const mo = Number(m[2]);
|
|
709
|
+
const d = Number(m[3]);
|
|
710
|
+
const dt = new Date(Date.UTC(y, mo - 1, d));
|
|
711
|
+
return (
|
|
712
|
+
dt.getUTCFullYear() === y &&
|
|
713
|
+
dt.getUTCMonth() === mo - 1 &&
|
|
714
|
+
dt.getUTCDate() === d
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function isStale(accessed: string, now: Date, days: number): boolean {
|
|
719
|
+
const t = Date.parse(accessed);
|
|
720
|
+
if (Number.isNaN(t)) return false;
|
|
721
|
+
const ageMs = now.getTime() - t;
|
|
722
|
+
return ageMs > days * 24 * 60 * 60 * 1000;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export function formatDiagnostics(diagnostics: Diagnostic[], filename?: string): string {
|
|
726
|
+
if (diagnostics.length === 0) return "✓ No issues found.";
|
|
727
|
+
const lines: string[] = [];
|
|
728
|
+
for (const d of diagnostics) {
|
|
729
|
+
const where = d.pos ? `${filename ?? "input"}:${d.pos.line}:${d.pos.column}` : (filename ?? "");
|
|
730
|
+
lines.push(`${d.severity.toUpperCase()} [${d.code}] ${where}: ${d.message}`);
|
|
731
|
+
}
|
|
732
|
+
return lines.join("\n");
|
|
733
|
+
}
|