@dotit/core 1.0.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 +229 -0
- package/dist/aliases.d.ts +1 -0
- package/dist/aliases.js +8 -0
- package/dist/ask.d.ts +7 -0
- package/dist/ask.js +55 -0
- package/dist/browser.d.ts +12 -0
- package/dist/browser.js +32 -0
- package/dist/diff.d.ts +17 -0
- package/dist/diff.js +179 -0
- package/dist/document-css.d.ts +1 -0
- package/dist/document-css.js +290 -0
- package/dist/executor.d.ts +40 -0
- package/dist/executor.js +501 -0
- package/dist/history.d.ts +10 -0
- package/dist/history.js +297 -0
- package/dist/html-to-it.d.ts +1 -0
- package/dist/html-to-it.js +288 -0
- package/dist/index-builder.d.ts +62 -0
- package/dist/index-builder.js +228 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +94 -0
- package/dist/language-registry.d.ts +39 -0
- package/dist/language-registry.js +530 -0
- package/dist/markdown.d.ts +1 -0
- package/dist/markdown.js +123 -0
- package/dist/merge.d.ts +6 -0
- package/dist/merge.js +255 -0
- package/dist/parser.d.ts +29 -0
- package/dist/parser.js +1562 -0
- package/dist/query.d.ts +32 -0
- package/dist/query.js +293 -0
- package/dist/renderer.d.ts +16 -0
- package/dist/renderer.js +1286 -0
- package/dist/schema.d.ts +47 -0
- package/dist/schema.js +574 -0
- package/dist/source.d.ts +3 -0
- package/dist/source.js +223 -0
- package/dist/theme.d.ts +49 -0
- package/dist/theme.js +113 -0
- package/dist/themes/corporate.json +86 -0
- package/dist/themes/dark.json +64 -0
- package/dist/themes/editorial.json +54 -0
- package/dist/themes/legal.json +57 -0
- package/dist/themes/minimal.json +50 -0
- package/dist/themes/print.json +54 -0
- package/dist/themes/technical.json +59 -0
- package/dist/themes/warm.json +53 -0
- package/dist/trust.d.ts +66 -0
- package/dist/trust.js +200 -0
- package/dist/types.d.ts +234 -0
- package/dist/types.js +19 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +13 -0
- package/dist/validate.d.ts +13 -0
- package/dist/validate.js +711 -0
- package/dist/workflow.d.ts +18 -0
- package/dist/workflow.js +160 -0
- package/package.json +51 -0
package/dist/validate.js
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateDocumentSemantic = validateDocumentSemantic;
|
|
4
|
+
const utils_1 = require("./utils");
|
|
5
|
+
function validateDocumentSemantic(doc) {
|
|
6
|
+
if (!doc || !Array.isArray(doc.blocks)) {
|
|
7
|
+
return { valid: true, issues: [] };
|
|
8
|
+
}
|
|
9
|
+
const issues = [];
|
|
10
|
+
const allBlocks = (0, utils_1.flattenBlocks)(doc.blocks);
|
|
11
|
+
const DATE_KEYS = new Set(["date", "due", "at", "expires", "issued"]);
|
|
12
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
13
|
+
for (const block of allBlocks) {
|
|
14
|
+
if (!block.properties)
|
|
15
|
+
continue;
|
|
16
|
+
for (const [key, raw] of Object.entries(block.properties)) {
|
|
17
|
+
if (!DATE_KEYS.has(key))
|
|
18
|
+
continue;
|
|
19
|
+
const value = String(raw).trim();
|
|
20
|
+
if (!value || value.includes("{{"))
|
|
21
|
+
continue;
|
|
22
|
+
if (!ISO_DATE_RE.test(value)) {
|
|
23
|
+
issues.push({
|
|
24
|
+
blockId: block.id,
|
|
25
|
+
blockType: block.type,
|
|
26
|
+
type: "warning",
|
|
27
|
+
code: "DATE_NOT_ISO",
|
|
28
|
+
message: `'${key}: ${value}' is not ISO 8601 — use YYYY-MM-DD (e.g. 2026-03-09) so date queries and sorting work reliably`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const stepIds = new Set();
|
|
34
|
+
const seenExplicitIds = new Map();
|
|
35
|
+
for (const block of allBlocks) {
|
|
36
|
+
const explicitId = block.properties?.id != null ? String(block.properties.id) : null;
|
|
37
|
+
if (explicitId) {
|
|
38
|
+
if (seenExplicitIds.has(explicitId)) {
|
|
39
|
+
issues.push({
|
|
40
|
+
blockId: block.id,
|
|
41
|
+
blockType: block.type,
|
|
42
|
+
type: "error",
|
|
43
|
+
code: "DUPLICATE_STEP_ID",
|
|
44
|
+
message: `Duplicate id "${explicitId}" — also used by a ${seenExplicitIds.get(explicitId).type} block`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
seenExplicitIds.set(explicitId, block);
|
|
49
|
+
}
|
|
50
|
+
stepIds.add(explicitId);
|
|
51
|
+
}
|
|
52
|
+
stepIds.add(block.id);
|
|
53
|
+
}
|
|
54
|
+
const declaredVars = new Set();
|
|
55
|
+
const contextBlock = allBlocks.find((b) => b.type === "context");
|
|
56
|
+
if (contextBlock?.properties) {
|
|
57
|
+
for (const key of Object.keys(contextBlock.properties)) {
|
|
58
|
+
declaredVars.add(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (doc.metadata?.context) {
|
|
62
|
+
for (const key of Object.keys(doc.metadata.context)) {
|
|
63
|
+
declaredVars.add(key);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const block of allBlocks) {
|
|
67
|
+
if (block.type === "step" && block.properties?.output) {
|
|
68
|
+
declaredVars.add(String(block.properties.output));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const hasMergePlaceholders = allBlocks.some((b) => /\{\{/.test(b.originalContent || b.content || "") ||
|
|
72
|
+
Object.values(b.properties || {}).some((v) => /\{\{/.test(String(v))));
|
|
73
|
+
const isTemplate = doc.metadata?.meta?.type === "template" ||
|
|
74
|
+
allBlocks.some((b) => b.type === "input") ||
|
|
75
|
+
(declaredVars.size === 0 && hasMergePlaceholders);
|
|
76
|
+
let lastSection = null;
|
|
77
|
+
let blocksInCurrentSection = 0;
|
|
78
|
+
for (let i = 0; i < allBlocks.length; i++) {
|
|
79
|
+
const block = allBlocks[i];
|
|
80
|
+
const eType = block.type;
|
|
81
|
+
if (block.type === "section") {
|
|
82
|
+
if (lastSection && blocksInCurrentSection === 0) {
|
|
83
|
+
issues.push({
|
|
84
|
+
blockId: lastSection.id,
|
|
85
|
+
blockType: lastSection.type,
|
|
86
|
+
type: "warning",
|
|
87
|
+
code: "EMPTY_SECTION",
|
|
88
|
+
message: `Section "${lastSection.content}" is empty`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
lastSection = block;
|
|
92
|
+
blocksInCurrentSection = 0;
|
|
93
|
+
}
|
|
94
|
+
else if (lastSection) {
|
|
95
|
+
blocksInCurrentSection++;
|
|
96
|
+
}
|
|
97
|
+
if (block.type === "decision") {
|
|
98
|
+
const thenRef = block.properties?.then
|
|
99
|
+
? String(block.properties.then)
|
|
100
|
+
: null;
|
|
101
|
+
const elseRef = block.properties?.else
|
|
102
|
+
? String(block.properties.else)
|
|
103
|
+
: null;
|
|
104
|
+
if (thenRef && !stepIds.has(thenRef)) {
|
|
105
|
+
issues.push({
|
|
106
|
+
blockId: block.id,
|
|
107
|
+
blockType: block.type,
|
|
108
|
+
type: "error",
|
|
109
|
+
code: "STEP_REF_MISSING",
|
|
110
|
+
message: `decision "then" references step "${thenRef}" which does not exist`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (elseRef && !stepIds.has(elseRef)) {
|
|
114
|
+
issues.push({
|
|
115
|
+
blockId: block.id,
|
|
116
|
+
blockType: block.type,
|
|
117
|
+
type: "error",
|
|
118
|
+
code: "STEP_REF_MISSING",
|
|
119
|
+
message: `decision "else" references step "${elseRef}" which does not exist`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (block.type === "step" && block.properties?.depends) {
|
|
124
|
+
const deps = String(block.properties.depends)
|
|
125
|
+
.split(",")
|
|
126
|
+
.map((s) => s.trim());
|
|
127
|
+
for (const dep of deps) {
|
|
128
|
+
if (dep && !stepIds.has(dep)) {
|
|
129
|
+
issues.push({
|
|
130
|
+
blockId: block.id,
|
|
131
|
+
blockType: block.type,
|
|
132
|
+
type: "error",
|
|
133
|
+
code: "DEPENDS_REF_MISSING",
|
|
134
|
+
message: `step depends on "${dep}" which does not exist`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (eType === "parallel" && block.properties?.steps) {
|
|
140
|
+
const refs = String(block.properties.steps)
|
|
141
|
+
.split(",")
|
|
142
|
+
.map((s) => s.trim());
|
|
143
|
+
for (const ref of refs) {
|
|
144
|
+
if (ref && !stepIds.has(ref)) {
|
|
145
|
+
issues.push({
|
|
146
|
+
blockId: block.id,
|
|
147
|
+
blockType: block.type,
|
|
148
|
+
type: "error",
|
|
149
|
+
code: "PARALLEL_REF_MISSING",
|
|
150
|
+
message: `parallel references step "${ref}" which does not exist`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (eType === "call") {
|
|
156
|
+
const titleBlock = allBlocks.find((b) => b.type === "title");
|
|
157
|
+
const callTarget = block.content || String(block.properties?.to || "");
|
|
158
|
+
if (titleBlock && callTarget && callTarget === titleBlock.content) {
|
|
159
|
+
issues.push({
|
|
160
|
+
blockId: block.id,
|
|
161
|
+
blockType: block.type,
|
|
162
|
+
type: "error",
|
|
163
|
+
code: "CALL_LOOP",
|
|
164
|
+
message: `call references the document's own title "${callTarget}"`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (block.type === "result") {
|
|
169
|
+
const remaining = allBlocks.slice(i + 1);
|
|
170
|
+
const nextNonResult = remaining.find((b) => b.type !== "section" && b.type !== "result");
|
|
171
|
+
const nextSection = remaining.find((b) => b.type === "section");
|
|
172
|
+
if (nextNonResult &&
|
|
173
|
+
(!nextSection ||
|
|
174
|
+
allBlocks.indexOf(nextNonResult) < allBlocks.indexOf(nextSection))) {
|
|
175
|
+
issues.push({
|
|
176
|
+
blockId: block.id,
|
|
177
|
+
blockType: block.type,
|
|
178
|
+
type: "error",
|
|
179
|
+
code: "RESULT_NOT_TERMINAL",
|
|
180
|
+
message: "result block is not the last block in its section",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (block.type === "gate" && !block.properties?.approver) {
|
|
185
|
+
issues.push({
|
|
186
|
+
blockId: block.id,
|
|
187
|
+
blockType: block.type,
|
|
188
|
+
type: "warning",
|
|
189
|
+
code: "GATE_NO_APPROVER",
|
|
190
|
+
message: "gate block has no approver property",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (block.type === "step" && !block.properties?.tool) {
|
|
194
|
+
issues.push({
|
|
195
|
+
blockId: block.id,
|
|
196
|
+
blockType: block.type,
|
|
197
|
+
type: "warning",
|
|
198
|
+
code: "STEP_NO_TOOL",
|
|
199
|
+
message: "step block has no tool property",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (eType === "handoff" && !block.properties?.to) {
|
|
203
|
+
issues.push({
|
|
204
|
+
blockId: block.id,
|
|
205
|
+
blockType: block.type,
|
|
206
|
+
type: "warning",
|
|
207
|
+
code: "HANDOFF_NO_TO",
|
|
208
|
+
message: "handoff block has no 'to' property",
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (eType === "retry" && !block.properties?.max) {
|
|
212
|
+
issues.push({
|
|
213
|
+
blockId: block.id,
|
|
214
|
+
blockType: block.type,
|
|
215
|
+
type: "warning",
|
|
216
|
+
code: "RETRY_NO_MAX",
|
|
217
|
+
message: "retry block has no 'max' property",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (block.type === "policy") {
|
|
221
|
+
const hasCondition = block.properties?.if ||
|
|
222
|
+
block.properties?.always ||
|
|
223
|
+
block.properties?.never;
|
|
224
|
+
if (!hasCondition && !isTemplate) {
|
|
225
|
+
issues.push({
|
|
226
|
+
blockId: block.id,
|
|
227
|
+
blockType: block.type,
|
|
228
|
+
type: "warning",
|
|
229
|
+
code: "POLICY_NO_CONDITION",
|
|
230
|
+
message: `Policy "${block.content}" has no condition (if:, always:, or never:). Add a condition or use note: instead.`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (block.properties?.if &&
|
|
234
|
+
!block.properties?.action &&
|
|
235
|
+
!block.properties?.notify &&
|
|
236
|
+
!block.properties?.requires) {
|
|
237
|
+
issues.push({
|
|
238
|
+
blockId: block.id,
|
|
239
|
+
blockType: block.type,
|
|
240
|
+
type: "warning",
|
|
241
|
+
code: "POLICY_NO_ACTION",
|
|
242
|
+
message: `Policy "${block.content}" has a condition but no action. Add action:, notify:, or requires:.`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (block.type === "cite" && !block.content && !block.properties?.url) {
|
|
247
|
+
issues.push({
|
|
248
|
+
blockId: block.id,
|
|
249
|
+
blockType: "cite",
|
|
250
|
+
type: "warning",
|
|
251
|
+
code: "CITE_MISSING_TITLE",
|
|
252
|
+
message: "cite block has no title and no url",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (eType === "input" && !block.content) {
|
|
256
|
+
issues.push({
|
|
257
|
+
blockId: block.id,
|
|
258
|
+
blockType: "input",
|
|
259
|
+
type: "warning",
|
|
260
|
+
code: "INPUT_MISSING_NAME",
|
|
261
|
+
message: "input block has no parameter name",
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (eType === "output" && !block.content) {
|
|
265
|
+
issues.push({
|
|
266
|
+
blockId: block.id,
|
|
267
|
+
blockType: "output",
|
|
268
|
+
type: "warning",
|
|
269
|
+
code: "OUTPUT_MISSING_NAME",
|
|
270
|
+
message: "output block has no parameter name",
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (eType === "tool" && !block.content && !block.properties?.api) {
|
|
274
|
+
issues.push({
|
|
275
|
+
blockId: block.id,
|
|
276
|
+
blockType: "tool",
|
|
277
|
+
type: "warning",
|
|
278
|
+
code: "TOOL_MISSING_API",
|
|
279
|
+
message: "tool block has no name and no api property",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (eType === "prompt" && !block.content) {
|
|
283
|
+
issues.push({
|
|
284
|
+
blockId: block.id,
|
|
285
|
+
blockType: "prompt",
|
|
286
|
+
type: "warning",
|
|
287
|
+
code: "PROMPT_MISSING_CONTENT",
|
|
288
|
+
message: "prompt block has no prompt text",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (eType === "assert" && !block.content && !block.properties?.expect) {
|
|
292
|
+
issues.push({
|
|
293
|
+
blockId: block.id,
|
|
294
|
+
blockType: "assert",
|
|
295
|
+
type: "warning",
|
|
296
|
+
code: "ASSERT_MISSING_CONDITION",
|
|
297
|
+
message: "assert block has no description and no expect property",
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
if (eType === "secret" && !block.content) {
|
|
301
|
+
issues.push({
|
|
302
|
+
blockId: block.id,
|
|
303
|
+
blockType: "secret",
|
|
304
|
+
type: "error",
|
|
305
|
+
code: "SECRET_MISSING_NAME",
|
|
306
|
+
message: "secret block has no name",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
if (!isTemplate) {
|
|
310
|
+
checkUnresolvedVars(block, declaredVars, issues);
|
|
311
|
+
}
|
|
312
|
+
if (block.type === "approve" && !block.properties?.by) {
|
|
313
|
+
issues.push({
|
|
314
|
+
blockId: block.id,
|
|
315
|
+
blockType: block.type,
|
|
316
|
+
type: "error",
|
|
317
|
+
code: "APPROVE_NO_BY",
|
|
318
|
+
message: "approve block has no 'by' property",
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (block.type === "sign" && !block.properties?.hash) {
|
|
322
|
+
issues.push({
|
|
323
|
+
blockId: block.id,
|
|
324
|
+
blockType: block.type,
|
|
325
|
+
type: "error",
|
|
326
|
+
code: "SIGN_NO_HASH",
|
|
327
|
+
message: "sign block has no 'hash' property",
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (block.type === "sign" && !block.properties?.at) {
|
|
331
|
+
issues.push({
|
|
332
|
+
blockId: block.id,
|
|
333
|
+
blockType: block.type,
|
|
334
|
+
type: "error",
|
|
335
|
+
code: "SIGN_NO_AT",
|
|
336
|
+
message: "sign block has no 'at' property",
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (lastSection && blocksInCurrentSection === 0) {
|
|
341
|
+
issues.push({
|
|
342
|
+
blockId: lastSection.id,
|
|
343
|
+
blockType: lastSection.type,
|
|
344
|
+
type: "warning",
|
|
345
|
+
code: "EMPTY_SECTION",
|
|
346
|
+
message: `Section "${lastSection.content}" is empty`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
const freezeBlocks = allBlocks.filter((b) => b.type === "freeze");
|
|
350
|
+
if (freezeBlocks.length > 1) {
|
|
351
|
+
issues.push({
|
|
352
|
+
blockId: freezeBlocks[1].id,
|
|
353
|
+
blockType: "freeze",
|
|
354
|
+
type: "error",
|
|
355
|
+
code: "MULTIPLE_FREEZE",
|
|
356
|
+
message: "More than one freeze: block found — only one is allowed",
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (freezeBlocks.length === 1) {
|
|
360
|
+
const freezeIdx = allBlocks.indexOf(freezeBlocks[0]);
|
|
361
|
+
const blocksAfterFreeze = allBlocks.slice(freezeIdx + 1);
|
|
362
|
+
const nonAmendmentAfterFreeze = blocksAfterFreeze.filter((b) => b.type !== "amendment" && b.type !== "sign");
|
|
363
|
+
if (nonAmendmentAfterFreeze.length > 0) {
|
|
364
|
+
issues.push({
|
|
365
|
+
blockId: freezeBlocks[0].id,
|
|
366
|
+
blockType: "freeze",
|
|
367
|
+
type: "error",
|
|
368
|
+
code: "FREEZE_NOT_LAST",
|
|
369
|
+
message: "freeze: block is not the last block before the history boundary (amendment: and sign: are allowed after freeze:)",
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (doc.history && freezeBlocks.length === 0) {
|
|
374
|
+
issues.push({
|
|
375
|
+
blockId: "",
|
|
376
|
+
blockType: "history",
|
|
377
|
+
type: "warning",
|
|
378
|
+
code: "HISTORY_WITHOUT_FREEZE",
|
|
379
|
+
message: "Document has a history section but no freeze: block — " +
|
|
380
|
+
"this may indicate manual editing or a broken seal.",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
if (doc.metadata?.tracking && !doc.metadata.tracking.version) {
|
|
384
|
+
issues.push({
|
|
385
|
+
blockId: "",
|
|
386
|
+
blockType: "track",
|
|
387
|
+
type: "error",
|
|
388
|
+
code: "TRACK_NO_VERSION",
|
|
389
|
+
message: "track block has no 'version' property",
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
if (doc.metadata?.tracking && !allBlocks.some((b) => b.type === "title")) {
|
|
393
|
+
issues.push({
|
|
394
|
+
blockId: "",
|
|
395
|
+
blockType: "track",
|
|
396
|
+
type: "warning",
|
|
397
|
+
code: "TRACK_WITHOUT_TITLE",
|
|
398
|
+
message: "Document has track: but no title: — tracked documents should have a title",
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (freezeBlocks.length > 0 && !allBlocks.some((b) => b.type === "sign")) {
|
|
402
|
+
issues.push({
|
|
403
|
+
blockId: freezeBlocks[0].id,
|
|
404
|
+
blockType: "freeze",
|
|
405
|
+
type: "warning",
|
|
406
|
+
code: "FREEZE_UNSIGNED",
|
|
407
|
+
message: "Document is frozen but has no sign: blocks — consider adding signatures before sealing",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
const signBlocks = allBlocks.filter((b) => b.type === "sign");
|
|
411
|
+
if (signBlocks.length > 0 && doc.metadata?.signatures) {
|
|
412
|
+
for (let si = 0; si < signBlocks.length; si++) {
|
|
413
|
+
const signBlock = signBlocks[si];
|
|
414
|
+
const hash = signBlock.properties?.hash
|
|
415
|
+
? String(signBlock.properties.hash)
|
|
416
|
+
: "";
|
|
417
|
+
if (hash && doc.metadata.signatures[si]?.hash) {
|
|
418
|
+
if (doc.metadata.signatures[si].valid === false) {
|
|
419
|
+
issues.push({
|
|
420
|
+
blockId: signBlock.id,
|
|
421
|
+
blockType: "sign",
|
|
422
|
+
type: "warning",
|
|
423
|
+
code: "SIGN_HASH_INVALID",
|
|
424
|
+
message: `sign: hash does not match current document content — document was edited after signing`,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (!allBlocks.some((b) => b.type === "title")) {
|
|
431
|
+
issues.push({
|
|
432
|
+
blockId: "",
|
|
433
|
+
blockType: "",
|
|
434
|
+
type: "info",
|
|
435
|
+
code: "DOCUMENT_NO_TITLE",
|
|
436
|
+
message: "Document has no title block",
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
const hasTemplateVars = allBlocks.some((b) => {
|
|
440
|
+
const text = b.originalContent || b.content || "";
|
|
441
|
+
return /\{\{[^}]+\}\}/.test(text);
|
|
442
|
+
});
|
|
443
|
+
if (hasTemplateVars) {
|
|
444
|
+
issues.push({
|
|
445
|
+
blockId: "",
|
|
446
|
+
blockType: "",
|
|
447
|
+
type: "info",
|
|
448
|
+
code: "TEMPLATE_HAS_UNRESOLVED",
|
|
449
|
+
message: "Document contains {{variable}} placeholders (template)",
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
const metaBlocks = allBlocks.filter((b) => b.type === "meta");
|
|
453
|
+
for (const metaBlock of metaBlocks) {
|
|
454
|
+
issues.push({
|
|
455
|
+
blockId: metaBlock.id,
|
|
456
|
+
blockType: "meta",
|
|
457
|
+
type: "warning",
|
|
458
|
+
code: "META_AFTER_SECTION",
|
|
459
|
+
message: "meta: block appears after a section: — it will be treated as content, not metadata",
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const hasPage = allBlocks.some((b) => b.type === "page");
|
|
463
|
+
const headerBlocks = allBlocks.filter((b) => b.type === "header");
|
|
464
|
+
const footerBlocks = allBlocks.filter((b) => b.type === "footer");
|
|
465
|
+
const watermarkBlocks = allBlocks.filter((b) => b.type === "watermark");
|
|
466
|
+
if (headerBlocks.length > 0 && !hasPage) {
|
|
467
|
+
for (const hb of headerBlocks) {
|
|
468
|
+
issues.push({
|
|
469
|
+
blockId: hb.id,
|
|
470
|
+
blockType: "header",
|
|
471
|
+
type: "warning",
|
|
472
|
+
code: "HEADER_WITHOUT_PAGE",
|
|
473
|
+
message: "header: block present but no page: block found — header will have no effect",
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (footerBlocks.length > 0 && !hasPage) {
|
|
478
|
+
for (const fb of footerBlocks) {
|
|
479
|
+
issues.push({
|
|
480
|
+
blockId: fb.id,
|
|
481
|
+
blockType: "footer",
|
|
482
|
+
type: "warning",
|
|
483
|
+
code: "FOOTER_WITHOUT_PAGE",
|
|
484
|
+
message: "footer: block present but no page: block found — footer will have no effect",
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (watermarkBlocks.length > 0 && !hasPage) {
|
|
489
|
+
for (const wb of watermarkBlocks) {
|
|
490
|
+
issues.push({
|
|
491
|
+
blockId: wb.id,
|
|
492
|
+
blockType: "watermark",
|
|
493
|
+
type: "warning",
|
|
494
|
+
code: "WATERMARK_WITHOUT_PAGE",
|
|
495
|
+
message: "watermark: block present but no page: block found — watermark will have no effect",
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (watermarkBlocks.length > 1) {
|
|
500
|
+
for (let i = 0; i < watermarkBlocks.length - 1; i++) {
|
|
501
|
+
issues.push({
|
|
502
|
+
blockId: watermarkBlocks[i].id,
|
|
503
|
+
blockType: "watermark",
|
|
504
|
+
type: "warning",
|
|
505
|
+
code: "MULTIPLE_WATERMARKS",
|
|
506
|
+
message: "Multiple watermark: blocks found — only the last one will be used",
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const refBlocks = allBlocks.filter((b) => b.type === "ref");
|
|
511
|
+
for (const rb of refBlocks) {
|
|
512
|
+
const hasFile = rb.properties?.file != null;
|
|
513
|
+
const hasUrl = rb.properties?.url != null;
|
|
514
|
+
if (!hasFile && !hasUrl) {
|
|
515
|
+
issues.push({
|
|
516
|
+
blockId: rb.id,
|
|
517
|
+
blockType: "ref",
|
|
518
|
+
type: "error",
|
|
519
|
+
code: "REF_MISSING_TARGET",
|
|
520
|
+
message: "ref: block has no file: or url: property — target is required",
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
if (!rb.properties?.rel) {
|
|
524
|
+
issues.push({
|
|
525
|
+
blockId: rb.id,
|
|
526
|
+
blockType: "ref",
|
|
527
|
+
type: "warning",
|
|
528
|
+
code: "REF_MISSING_REL",
|
|
529
|
+
message: "ref: block has no rel: property — relationship type recommended",
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const defBlocks = allBlocks.filter((b) => b.type === "def");
|
|
534
|
+
const seenTerms = new Map();
|
|
535
|
+
for (const db of defBlocks) {
|
|
536
|
+
if (!db.properties?.meaning) {
|
|
537
|
+
issues.push({
|
|
538
|
+
blockId: db.id,
|
|
539
|
+
blockType: "def",
|
|
540
|
+
type: "error",
|
|
541
|
+
code: "DEF_MISSING_MEANING",
|
|
542
|
+
message: `def: "${db.content}" has no meaning: property`,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
const termKey = db.content.toLowerCase().trim();
|
|
546
|
+
if (seenTerms.has(termKey)) {
|
|
547
|
+
issues.push({
|
|
548
|
+
blockId: db.id,
|
|
549
|
+
blockType: "def",
|
|
550
|
+
type: "warning",
|
|
551
|
+
code: "DEF_DUPLICATE_TERM",
|
|
552
|
+
message: `def: "${db.content}" is defined more than once`,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
seenTerms.set(termKey, db);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const metricBlocks = allBlocks.filter((b) => b.type === "metric");
|
|
560
|
+
const validTrends = new Set(["up", "down", "stable", "at-risk"]);
|
|
561
|
+
for (const mb of metricBlocks) {
|
|
562
|
+
if (mb.properties?.value == null) {
|
|
563
|
+
issues.push({
|
|
564
|
+
blockId: mb.id,
|
|
565
|
+
blockType: "metric",
|
|
566
|
+
type: "error",
|
|
567
|
+
code: "METRIC_MISSING_VALUE",
|
|
568
|
+
message: `metric: "${mb.content}" has no value: property`,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
if (mb.properties?.trend != null &&
|
|
572
|
+
!validTrends.has(String(mb.properties.trend)) &&
|
|
573
|
+
!isTemplate) {
|
|
574
|
+
issues.push({
|
|
575
|
+
blockId: mb.id,
|
|
576
|
+
blockType: "metric",
|
|
577
|
+
type: "warning",
|
|
578
|
+
code: "METRIC_INVALID_TREND",
|
|
579
|
+
message: `metric: "${mb.content}" has unknown trend "${mb.properties.trend}" — expected up, down, stable, or at-risk`,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const amendmentBlocks = allBlocks.filter((b) => b.type === "amendment");
|
|
584
|
+
const hasFreezeBlock = freezeBlocks.length > 0;
|
|
585
|
+
for (const ab of amendmentBlocks) {
|
|
586
|
+
if (!hasFreezeBlock) {
|
|
587
|
+
issues.push({
|
|
588
|
+
blockId: ab.id,
|
|
589
|
+
blockType: "amendment",
|
|
590
|
+
type: "error",
|
|
591
|
+
code: "AMENDMENT_WITHOUT_FREEZE",
|
|
592
|
+
message: "amendment: block in a document with no freeze: — amendments require a frozen document",
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
if (!ab.properties?.ref) {
|
|
596
|
+
issues.push({
|
|
597
|
+
blockId: ab.id,
|
|
598
|
+
blockType: "amendment",
|
|
599
|
+
type: "error",
|
|
600
|
+
code: "AMENDMENT_MISSING_REF",
|
|
601
|
+
message: `amendment: "${ab.content}" has no ref: property`,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
if (!ab.properties?.now) {
|
|
605
|
+
issues.push({
|
|
606
|
+
blockId: ab.id,
|
|
607
|
+
blockType: "amendment",
|
|
608
|
+
type: "error",
|
|
609
|
+
code: "AMENDMENT_MISSING_NOW",
|
|
610
|
+
message: `amendment: "${ab.content}" has no now: property`,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const figureBlocks = allBlocks.filter((b) => b.type === "figure");
|
|
615
|
+
for (const fb of figureBlocks) {
|
|
616
|
+
if (!fb.properties?.src) {
|
|
617
|
+
issues.push({
|
|
618
|
+
blockId: fb.id,
|
|
619
|
+
blockType: "figure",
|
|
620
|
+
type: "error",
|
|
621
|
+
code: "FIGURE_MISSING_SRC",
|
|
622
|
+
message: `figure: "${fb.content}" has no src: property`,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
if (!fb.properties?.caption && !isTemplate) {
|
|
626
|
+
issues.push({
|
|
627
|
+
blockId: fb.id,
|
|
628
|
+
blockType: "figure",
|
|
629
|
+
type: "warning",
|
|
630
|
+
code: "FIGURE_MISSING_CAPTION",
|
|
631
|
+
message: `figure: "${fb.content}" has no caption: property — figures should have captions`,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const contactBlocks = allBlocks.filter((b) => b.type === "contact");
|
|
636
|
+
for (const cb of contactBlocks) {
|
|
637
|
+
const hasEmail = cb.properties?.email != null;
|
|
638
|
+
const hasPhone = cb.properties?.phone != null;
|
|
639
|
+
const hasUrl2 = cb.properties?.url != null;
|
|
640
|
+
if (!hasEmail && !hasPhone && !hasUrl2) {
|
|
641
|
+
issues.push({
|
|
642
|
+
blockId: cb.id,
|
|
643
|
+
blockType: "contact",
|
|
644
|
+
type: "warning",
|
|
645
|
+
code: "CONTACT_NO_REACH",
|
|
646
|
+
message: `contact: "${cb.content}" has no email:, phone:, or url: — how do you reach them?`,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const deadlineBlocks = allBlocks.filter((b) => b.type === "deadline");
|
|
651
|
+
for (const dl of deadlineBlocks) {
|
|
652
|
+
if (!dl.properties?.date) {
|
|
653
|
+
issues.push({
|
|
654
|
+
blockId: dl.id,
|
|
655
|
+
blockType: "deadline",
|
|
656
|
+
type: "error",
|
|
657
|
+
code: "DEADLINE_MISSING_DATE",
|
|
658
|
+
message: `deadline: "${dl.content}" has no date: property`,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
const dateVal = new Date(String(dl.properties.date));
|
|
663
|
+
if (!isNaN(dateVal.getTime()) && dateVal.getTime() < Date.now()) {
|
|
664
|
+
issues.push({
|
|
665
|
+
blockId: dl.id,
|
|
666
|
+
blockType: "deadline",
|
|
667
|
+
type: "warning",
|
|
668
|
+
code: "DEADLINE_PAST",
|
|
669
|
+
message: `deadline: "${dl.content}" has a past date (${dl.properties.date})`,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const hasErrors = issues.some((i) => i.type === "error");
|
|
675
|
+
return { valid: !hasErrors, issues };
|
|
676
|
+
}
|
|
677
|
+
function checkUnresolvedVars(block, declaredVars, issues) {
|
|
678
|
+
const varPattern = /\{\{([^}]+)\}\}/g;
|
|
679
|
+
const text = block.originalContent || block.content || "";
|
|
680
|
+
let match;
|
|
681
|
+
while ((match = varPattern.exec(text)) !== null) {
|
|
682
|
+
const varName = match[1].trim();
|
|
683
|
+
if (!declaredVars.has(varName)) {
|
|
684
|
+
issues.push({
|
|
685
|
+
blockId: block.id,
|
|
686
|
+
blockType: block.type,
|
|
687
|
+
type: "warning",
|
|
688
|
+
code: "UNRESOLVED_VARIABLE",
|
|
689
|
+
message: `Unresolved variable "{{${varName}}}"`,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
if (block.properties) {
|
|
694
|
+
for (const val of Object.values(block.properties)) {
|
|
695
|
+
const strVal = String(val);
|
|
696
|
+
varPattern.lastIndex = 0;
|
|
697
|
+
while ((match = varPattern.exec(strVal)) !== null) {
|
|
698
|
+
const varName = match[1].trim();
|
|
699
|
+
if (!declaredVars.has(varName)) {
|
|
700
|
+
issues.push({
|
|
701
|
+
blockId: block.id,
|
|
702
|
+
blockType: block.type,
|
|
703
|
+
type: "warning",
|
|
704
|
+
code: "UNRESOLVED_VARIABLE",
|
|
705
|
+
message: `Unresolved variable "{{${varName}}}" in property value`,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|