structured-context 0.12.0 → 0.13.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.
Files changed (94) hide show
  1. package/README.md +29 -216
  2. package/dist/api.d.ts +17 -17
  3. package/dist/api.js +12 -12
  4. package/dist/commands/docs.js +1 -0
  5. package/dist/commands/providers.d.ts +4 -0
  6. package/dist/commands/providers.js +52 -0
  7. package/dist/commands/schemas.js +21 -2
  8. package/dist/commands/validate-file.d.ts +3 -0
  9. package/dist/commands/validate-file.js +17 -2
  10. package/dist/commands/validate.d.ts +4 -0
  11. package/dist/commands/validate.js +42 -3
  12. package/dist/config.d.ts +7 -0
  13. package/dist/config.js +26 -3
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +59 -18
  16. package/dist/judge/context.d.ts +20 -0
  17. package/dist/judge/context.js +105 -0
  18. package/dist/judge/factory.d.ts +44 -0
  19. package/dist/judge/factory.js +148 -0
  20. package/dist/judge/index.d.ts +13 -0
  21. package/dist/judge/index.js +8 -0
  22. package/dist/judge/judge-node.d.ts +27 -0
  23. package/dist/judge/judge-node.js +122 -0
  24. package/dist/judge/prompt.d.ts +10 -0
  25. package/dist/judge/prompt.js +92 -0
  26. package/dist/judge/provider/anthropic.d.ts +17 -0
  27. package/dist/judge/provider/anthropic.js +43 -0
  28. package/dist/judge/provider/fake.d.ts +26 -0
  29. package/dist/judge/provider/fake.js +56 -0
  30. package/dist/judge/provider/jev.d.ts +37 -0
  31. package/dist/judge/provider/jev.js +169 -0
  32. package/dist/judge/provider/openai-decisions.d.ts +21 -0
  33. package/dist/judge/provider/openai-decisions.js +84 -0
  34. package/dist/judge/provider/openai.d.ts +20 -0
  35. package/dist/judge/provider/openai.js +53 -0
  36. package/dist/judge/provider/per-rule.d.ts +10 -0
  37. package/dist/judge/provider/per-rule.js +18 -0
  38. package/dist/judge/trim.d.ts +27 -0
  39. package/dist/judge/trim.js +62 -0
  40. package/dist/judge/types.d.ts +92 -0
  41. package/dist/judge/types.js +9 -0
  42. package/dist/judge/verdicts.d.ts +24 -0
  43. package/dist/judge/verdicts.js +82 -0
  44. package/dist/load-space-graph.d.ts +3 -3
  45. package/dist/load-space-graph.js +4 -4
  46. package/dist/plugins/loader.js +1 -1
  47. package/dist/plugins/markdown/parse-embedded.d.ts +2 -0
  48. package/dist/plugins/markdown/parse-embedded.js +161 -24
  49. package/dist/plugins/markdown/read-space.js +4 -9
  50. package/dist/plugins/markdown/render-directory.js +5 -5
  51. package/dist/plugins/markdown/template-sync.js +3 -4
  52. package/dist/plugins/markdown/util.d.ts +26 -5
  53. package/dist/plugins/markdown/util.js +56 -15
  54. package/dist/render/render.d.ts +1 -1
  55. package/dist/render/render.js +1 -1
  56. package/dist/schema/evaluate-rule.d.ts +33 -2
  57. package/dist/schema/evaluate-rule.js +58 -4
  58. package/dist/schema/gating.d.ts +69 -0
  59. package/dist/schema/gating.js +253 -0
  60. package/dist/schema/metadata-contract.d.ts +625 -12
  61. package/dist/schema/metadata-contract.js +110 -4
  62. package/dist/schema/rule-filter.d.ts +12 -0
  63. package/dist/schema/rule-filter.js +46 -0
  64. package/dist/schema/rule-severity.d.ts +13 -0
  65. package/dist/schema/rule-severity.js +33 -0
  66. package/dist/schema/schema.js +31 -1
  67. package/dist/schema/validate-rules.d.ts +17 -8
  68. package/dist/schema/validate-rules.js +91 -48
  69. package/dist/schemas/_knowledge_wiki.json +4 -4
  70. package/dist/schemas/_okf.json +172 -0
  71. package/dist/schemas/_ost_strict.json +15 -6
  72. package/dist/schemas/_sctx_base.json +1 -1
  73. package/dist/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  74. package/dist/schemas/generated/latest/_structured_context_schema_meta.json +177 -6
  75. package/dist/schemas/okf.json +8 -0
  76. package/dist/space-context.d.ts +2 -2
  77. package/dist/space-context.js +3 -3
  78. package/dist/space-graph.d.ts +1 -1
  79. package/dist/testing.d.ts +1 -1
  80. package/dist/types.d.ts +11 -4
  81. package/dist/validate.d.ts +59 -9
  82. package/dist/validate.js +225 -120
  83. package/docs/concepts.md +52 -6
  84. package/docs/config.md +70 -1
  85. package/docs/rules.md +140 -49
  86. package/docs/schemas.md +43 -18
  87. package/package.json +6 -5
  88. package/schemas/_knowledge_wiki.json +4 -4
  89. package/schemas/_okf.json +172 -0
  90. package/schemas/_ost_strict.json +15 -6
  91. package/schemas/_sctx_base.json +1 -1
  92. package/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  93. package/schemas/generated/latest/_structured_context_schema_meta.json +177 -6
  94. package/schemas/okf.json +8 -0
@@ -1,10 +1,9 @@
1
- import { load as yamlLoad } from 'js-yaml';
2
1
  import { toString as mdastToString } from 'mdast-util-to-string';
3
2
  import remarkGfm from 'remark-gfm';
4
3
  import remarkParse from 'remark-parse';
5
4
  import { unified } from 'unified';
6
5
  import { extractLinksFromAstNode } from './extract-content-links';
7
- import { applyFieldMap, coerceDates } from './util';
6
+ import { applyFieldMap, parseYaml } from './util';
8
7
  /** Type values that identify a space_on_a_page container (not themselves space nodes). */
9
8
  export const ON_A_PAGE_TYPES = ['ost_on_a_page', 'space_on_a_page'];
10
9
  const DEFAULT_STATUS = 'identified';
@@ -141,18 +140,91 @@ export function defaultNodeType(stack, hierarchy) {
141
140
  }
142
141
  return hierarchy[idx + 1];
143
142
  }
144
- function appendContent(node, text) {
145
- if (!text)
143
+ /** Start and end offsets of an mdast node. Throws when the parser gave no position. */
144
+ function offsetsOf(node) {
145
+ const start = node.position?.start.offset;
146
+ const end = node.position?.end.offset;
147
+ if (start === undefined || end === undefined) {
148
+ throw new Error(`Markdown ${node.type} node has no source position; cannot slice its content`);
149
+ }
150
+ return { start, end };
151
+ }
152
+ function addRange(claims, node, start, end) {
153
+ if (end <= start)
146
154
  return;
147
- const existing = node.schemaData.content;
148
- const updated = existing ? `${existing}\n${text}` : text;
149
- node.schemaData.content = updated;
150
- node.content = updated;
155
+ const ranges = claims.ranges.get(node) ?? [];
156
+ const last = ranges[ranges.length - 1];
157
+ if (last && last.end === start)
158
+ last.end = end;
159
+ else
160
+ ranges.push({ start, end });
161
+ claims.ranges.set(node, ranges);
162
+ }
163
+ /**
164
+ * Give a node a block of source, plus the blank lines between `from` and the block's `start`.
165
+ * Those lines are skipped when a consumed block just before already kept its own.
166
+ */
167
+ function claim(claims, node, from, start, end) {
168
+ addRange(claims, node, claims.gapKept.delete(node) ? start : from, end);
169
+ }
170
+ /**
171
+ * Take a block out of a node's content (it was consumed as a node, field or other syntax).
172
+ * The blank lines before it stay, so the content on either side does not run together.
173
+ */
174
+ function consume(claims, node, from, start) {
175
+ addRange(claims, node, claims.gapKept.delete(node) ? start : from, start);
176
+ claims.gapKept.add(node);
177
+ }
178
+ /** Sets each node's content from its claimed source. Leading blank lines and trailing whitespace are dropped. */
179
+ function applyClaims(claims, body) {
180
+ for (const [node, ranges] of claims.ranges) {
181
+ let content = ranges
182
+ .map((r) => body.slice(r.start, r.end))
183
+ .join('')
184
+ .replace(/^(?:[ \t]*\r?\n)+/, '')
185
+ .trimEnd();
186
+ const indent = claims.contentIndent.get(node);
187
+ if (indent) {
188
+ const strip = new RegExp(`^[ \\t]{1,${indent}}`);
189
+ content = content
190
+ .split(/(?<=\n)/)
191
+ .map((line) => line.replace(strip, ''))
192
+ .join('');
193
+ }
194
+ if (!content)
195
+ continue;
196
+ node.schemaData.content = content;
197
+ node.content = content;
198
+ }
151
199
  }
152
- function processListItem(item, parentRef, contentTarget, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, parentFieldAppend, activeNodeFieldAppend, file, lineOffset) {
200
+ /** Source lines (with terminators) of a range, as absolute offsets. */
201
+ function sourceLines(body, range) {
202
+ const lines = [];
203
+ const re = /[^\n]*\n|[^\n]+$/g;
204
+ const text = body.slice(range.start, range.end);
205
+ for (const m of text.matchAll(re)) {
206
+ lines.push({
207
+ start: range.start + m.index,
208
+ end: range.start + m.index + m[0].length,
209
+ text: m[0].replace(/\r?\n$/, ''),
210
+ });
211
+ }
212
+ return lines;
213
+ }
214
+ /** Whether a line holds only inline fields: bracketed `[key:: value]` pieces, or one `key:: value`. */
215
+ function isFieldOnlyLine(line) {
216
+ const { cleanText, fields } = extractBracketedFields(line);
217
+ const hasBracketed = Object.keys(fields).length > 0;
218
+ if (cleanText === '')
219
+ return hasBracketed;
220
+ return /^[a-zA-Z][a-zA-Z0-9_-]*:: *.*$/.test(cleanText);
221
+ }
222
+ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, parentFieldAppend, activeNodeFieldAppend, file, lineOffset, walk) {
223
+ const itemEnd = offsetsOf(item).end;
153
224
  const firstPara = item.children.find((c) => c.type === 'paragraph');
154
225
  if (!firstPara) {
155
- appendContent(contentTarget, `- ${mdastToString(item)}`);
226
+ claim(walk.claims, contentTarget, walk.pos, offsetsOf(item).start, itemEnd);
227
+ walk.pos = itemEnd;
156
228
  return;
157
229
  }
158
230
  const rawText = mdastToString(firstPara);
@@ -161,11 +233,15 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
161
233
  if (wikiTarget) {
162
234
  const linkRef = `[[${wikiTarget}]]`;
163
235
  if (parentFieldAppend) {
236
+ consume(walk.claims, contentTarget, walk.pos, offsetsOf(item).start);
164
237
  appendParentField(parentFieldAppend.node, parentFieldAppend.field, linkRef);
238
+ walk.pos = itemEnd;
165
239
  return;
166
240
  }
167
241
  if (activeNodeFieldAppend) {
242
+ consume(walk.claims, contentTarget, walk.pos, offsetsOf(item).start);
168
243
  appendParentField(activeNodeFieldAppend.node, activeNodeFieldAppend.field, linkRef);
244
+ walk.pos = itemEnd;
169
245
  return;
170
246
  }
171
247
  // No field append context — fall through to node creation or content append
@@ -204,21 +280,34 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
204
280
  ...(source ? { source } : {}),
205
281
  };
206
282
  nodes.push(newNode);
283
+ const contentColumn = firstPara.position?.start.column;
284
+ if (contentColumn !== undefined)
285
+ walk.claims.contentIndent.set(newNode, contentColumn - 1);
207
286
  if (parentFieldAppend) {
208
287
  appendParentField(parentFieldAppend.node, parentFieldAppend.field, `[[${linkTargets[0] ?? title}]]`);
209
288
  }
210
289
  const nestedParentRef = `[[${linkTargets[0] ?? title}]]`;
290
+ consume(walk.claims, contentTarget, walk.pos, offsetsOf(item).start);
291
+ walk.pos = offsetsOf(firstPara).end;
211
292
  for (const child of item.children) {
293
+ if (child === firstPara)
294
+ continue;
212
295
  if (child.type === 'list') {
213
296
  for (const subItem of child.children) {
214
- processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, undefined, undefined, file, lineOffset);
297
+ processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, undefined, undefined, file, lineOffset, walk);
215
298
  }
216
299
  }
300
+ else {
301
+ const { start, end } = offsetsOf(child);
302
+ claim(walk.claims, newNode, walk.pos, start, end);
303
+ walk.pos = end;
304
+ }
217
305
  }
218
306
  }
219
307
  else {
220
- appendContent(contentTarget, `- ${rawText}`);
308
+ claim(walk.claims, contentTarget, walk.pos, offsetsOf(item).start, itemEnd);
221
309
  }
310
+ walk.pos = itemEnd;
222
311
  }
223
312
  /**
224
313
  * Extract space nodes from markdown body text.
@@ -418,7 +507,32 @@ export function extractEmbeddedNodes(body, options) {
418
507
  }
419
508
  let grouping = null;
420
509
  let activeNode = rootNode;
510
+ const claims = { ranges: new Map(), gapKept: new Set(), contentIndent: new Map() };
511
+ /** End of the previous top-level block; a block's span starts here so the blank lines before it stay with it. */
512
+ let prevEnd = 0;
421
513
  for (const child of tree.children) {
514
+ const { start: blockStart, end: blockEnd } = offsetsOf(child);
515
+ const spanStart = prevEnd;
516
+ prevEnd = blockEnd;
517
+ /** Give a node this block as source content, minus any `removed` ranges inside it. */
518
+ const claimBlock = (node, removed = []) => {
519
+ let from = blockStart;
520
+ let first = true;
521
+ for (const gap of removed) {
522
+ if (first)
523
+ claim(claims, node, spanStart, blockStart, gap.start);
524
+ else
525
+ addRange(claims, node, from, gap.start);
526
+ first = false;
527
+ from = gap.end;
528
+ }
529
+ if (first)
530
+ claim(claims, node, spanStart, blockStart, blockEnd);
531
+ else
532
+ addRange(claims, node, from, blockEnd);
533
+ };
534
+ /** Take this whole block out of a node's content, keeping the blank lines before it. */
535
+ const consumeBlock = (node) => consume(claims, node, spanStart, blockStart);
422
536
  if (parseState === 'done') {
423
537
  if (child.type === 'heading') {
424
538
  const rawTitle = mdastToString(child);
@@ -436,8 +550,11 @@ export function extractEmbeddedNodes(body, options) {
436
550
  if (child.type === 'heading') {
437
551
  const heading = child;
438
552
  const depth = heading.depth;
439
- if (depth > 5)
553
+ if (depth > 5) {
554
+ if (parseState === 'active')
555
+ claimBlock(activeNode);
440
556
  continue;
557
+ }
441
558
  parseState = 'active';
442
559
  const rawText = mdastToString(heading);
443
560
  const { cleanText: afterBracketed, fields: rawInlineFields } = extractBracketedFields(rawText);
@@ -455,6 +572,10 @@ export function extractEmbeddedNodes(body, options) {
455
572
  stack.push({ depth, title, nodeType: '', refTarget: title });
456
573
  // Discard any pending grouping (untyped heading has no implied type)
457
574
  grouping = null;
575
+ // An untyped heading is part of the body of the nearest enclosing node. Popping above removed every
576
+ // node at the same or a higher level, so those sections end here; deeper nodes' sections continue.
577
+ activeNode = stack.findLast((entry) => entry.node)?.node ?? rootNode;
578
+ claimBlock(activeNode);
458
579
  continue;
459
580
  }
460
581
  if (isOnAPageMode && stack.length > 0) {
@@ -494,7 +615,7 @@ export function extractEmbeddedNodes(body, options) {
494
615
  };
495
616
  // Push to stack BEFORE resolving semantic parent — stack[-2] is the correct parent.
496
617
  const refTarget = linkTargets[0] ?? title;
497
- stack.push({ depth, title, nodeType: type, refTarget });
618
+ stack.push({ depth, title, nodeType: type, refTarget, node: headingNode });
498
619
  // If this match came from a relationship/hierarchy and no explicit type was given,
499
620
  // create a grouping — delay adding the node until we see the following content.
500
621
  // Discard any previous grouping (not flushed — original agnostic-parse behaviour).
@@ -532,14 +653,16 @@ export function extractEmbeddedNodes(body, options) {
532
653
  const activeNodeFieldAppendArg = isParentLevelMatch && semanticParent.node
533
654
  ? { node: semanticParent.node, field: definition.field }
534
655
  : undefined;
656
+ const walk = { claims, pos: spanStart };
535
657
  for (const item of list.children) {
536
- processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg, options.file, options.lineOffset);
658
+ processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg, options.file, options.lineOffset, walk);
537
659
  }
538
660
  grouping = null;
539
661
  }
540
662
  else {
663
+ const walk = { claims, pos: spanStart };
541
664
  for (const item of list.children) {
542
- processListItem(item, parentRef, activeNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, undefined, undefined, undefined, options.file, options.lineOffset);
665
+ processListItem(item, parentRef, activeNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, undefined, undefined, undefined, options.file, options.lineOffset, walk);
543
666
  }
544
667
  }
545
668
  }
@@ -648,7 +771,7 @@ export function extractEmbeddedNodes(body, options) {
648
771
  grouping = null;
649
772
  }
650
773
  else {
651
- appendContent(activeNode, mdastToString(child));
774
+ claimBlock(activeNode);
652
775
  }
653
776
  }
654
777
  }
@@ -662,38 +785,52 @@ export function extractEmbeddedNodes(body, options) {
662
785
  if (child.type === 'paragraph') {
663
786
  const rawText = mdastToString(child);
664
787
  const { cleanText: afterBracketed, fields: bracketedFields } = extractBracketedFields(rawText);
665
- const { remainingText, fields: unbracketedFields } = extractUnbracketedFields(afterBracketed);
788
+ const { fields: unbracketedFields } = extractUnbracketedFields(afterBracketed);
666
789
  const allFields = applyFieldMap({ ...unbracketedFields, ...bracketedFields }, fieldMap);
667
790
  if ('type' in allFields) {
668
791
  throw new Error(`Type override via paragraph field is not supported at "${activeNode.schemaData.title ?? activeNode.label}". ` +
669
792
  `Put [type:: ${allFields.type}] directly in the heading text.`);
670
793
  }
671
794
  Object.assign(activeNode.schemaData, allFields);
672
- if (remainingText)
673
- appendContent(activeNode, remainingText);
795
+ // Lines holding only fields are consumed as fields; any other line, including prose that mentions a field, is content.
796
+ const lines = sourceLines(body, { start: blockStart, end: blockEnd });
797
+ const fieldLines = lines.filter((l) => isFieldOnlyLine(l.text));
798
+ if (fieldLines.length === lines.length) {
799
+ consumeBlock(activeNode);
800
+ }
801
+ else {
802
+ // Each field line goes with its terminator; a last line without one takes the preceding line break instead.
803
+ claimBlock(activeNode, fieldLines.map((l) => {
804
+ if (/\n$/.test(body.slice(l.start, l.end)))
805
+ return { start: l.start, end: l.end };
806
+ return { start: l.start - (body[l.start - 2] === '\r' ? 2 : 1), end: l.end };
807
+ }));
808
+ }
674
809
  activeNode.contentLinks ??= [];
675
810
  activeNode.contentLinks.push(...extractLinksFromAstNode(child));
676
811
  }
677
812
  else if (child.type === 'code' && child.lang?.trim() === 'yaml') {
678
813
  const code = child;
679
- const parsed = yamlLoad(code.value);
814
+ const parsed = parseYaml(code.value);
680
815
  if (parsed && !Array.isArray(parsed) && typeof parsed === 'object') {
681
- Object.assign(activeNode.schemaData, coerceDates(applyFieldMap(parsed, fieldMap)));
816
+ Object.assign(activeNode.schemaData, applyFieldMap(parsed, fieldMap));
817
+ consumeBlock(activeNode);
682
818
  }
683
819
  else if (Array.isArray(parsed)) {
684
820
  throw new Error(`YAML block must be an object at "${activeNode.label}".`);
685
821
  }
686
822
  else {
687
- appendContent(activeNode, code.value);
823
+ claimBlock(activeNode);
688
824
  }
689
825
  }
690
826
  else {
691
- appendContent(activeNode, mdastToString(child));
827
+ claimBlock(activeNode);
692
828
  activeNode.contentLinks ??= [];
693
829
  activeNode.contentLinks.push(...extractLinksFromAstNode(child));
694
830
  }
695
831
  }
696
832
  }
833
+ applyClaims(claims, body);
697
834
  const preambleContent = rootNode.schemaData.content;
698
835
  const { type: _rootType, content: _rootContent, ...preambleFields } = rootNode.schemaData;
699
836
  return {
@@ -5,12 +5,12 @@ import matter from 'gray-matter';
5
5
  import { extractSchemaTypeNames } from '../../schema/schema';
6
6
  import { extractLinksFromBody, extractLinksFromFrontmatter, getEdgeFieldNames } from './extract-content-links';
7
7
  import { extractEmbeddedNodes, ON_A_PAGE_TYPES } from './parse-embedded';
8
- import { applyFieldMap, coerceDates, inferTypeFromPath } from './util';
8
+ import { applyFieldMap, inferTypeFromPath, MATTER_OPTIONS } from './util';
9
9
  export function readSpaceOnAPage(context) {
10
10
  const { space, resolvedSchemaPath, schema: { metadata }, } = context;
11
11
  const filePath = resolve(space.path);
12
12
  const raw = readFileSync(filePath, 'utf-8');
13
- const { data: frontmatter, content: body } = matter(raw);
13
+ const { data: frontmatter, content: body } = matter(raw, MATTER_OPTIONS);
14
14
  const pageType = frontmatter.type;
15
15
  if (pageType !== undefined && !ON_A_PAGE_TYPES.includes(pageType)) {
16
16
  throw new Error(`Expected a space_on_a_page file but got type "${pageType}" in ${filePath}. ` +
@@ -60,14 +60,9 @@ export async function readSpaceDirectory(context, options) {
60
60
  const content = readFileSync(join(directory, file), 'utf-8');
61
61
  let parsed;
62
62
  try {
63
- parsed = matter(content);
63
+ parsed = matter(content, MATTER_OPTIONS);
64
64
  }
65
65
  catch (err) {
66
- // gray-matter caches parsed YAML and has a known bug where a caught exception
67
- // corrupts its internal cache, causing subsequent parses to silently return {}.
68
- // Clear the cache after any parse error to avoid stale state.
69
- // See: https://github.com/jonschlinkert/gray-matter/issues/166
70
- matter.clearCache();
71
66
  const yamlErr = err;
72
67
  const line = yamlErr.mark ? yamlErr.mark.line + 1 : undefined;
73
68
  const column = yamlErr.mark ? yamlErr.mark.column + 1 : undefined;
@@ -91,7 +86,7 @@ export async function readSpaceDirectory(context, options) {
91
86
  });
92
87
  continue;
93
88
  }
94
- const data = coerceDates(applyFieldMap(parsed.data, fieldMap));
89
+ const data = applyFieldMap(parsed.data, fieldMap);
95
90
  if (!data.type && typeInferenceCfg && knownTypes) {
96
91
  data.type = inferTypeFromPath(file, typeInferenceCfg, knownTypes, context.schema.metadata.typeAliases);
97
92
  }
@@ -1,7 +1,7 @@
1
1
  import { posix } from 'node:path';
2
2
  import matter from 'gray-matter';
3
- import { getMarkdownConfig } from './index';
4
- import { coerceDates, invertFieldMap } from './util';
3
+ import { getMarkdownConfig } from '../../index.js';
4
+ import { invertFieldMap, MATTER_OPTIONS } from './util';
5
5
  /**
6
6
  * Sanitize a node title into a valid filesystem filename.
7
7
  * Strips Obsidian/OS forbidden characters: \ / : * ? " < > | # ^ [ ]
@@ -112,11 +112,11 @@ export function renderMarkdownDirectory(context, graph, options) {
112
112
  const typeKey = reverseFieldMap.type ?? 'type';
113
113
  frontmatterData[typeKey] = nodeType;
114
114
  }
115
- const coercedData = coerceDates(sortFrontmatterKeys(frontmatterData));
115
+ const sortedData = sortFrontmatterKeys(frontmatterData);
116
116
  const body = (node.content ?? '').trim();
117
117
  let fileContent;
118
- if (Object.keys(coercedData).length > 0) {
119
- fileContent = matter.stringify(body ? `\n${body}\n` : '', coercedData);
118
+ if (Object.keys(sortedData).length > 0) {
119
+ fileContent = matter.stringify(body ? `\n${body}\n` : '', sortedData, MATTER_OPTIONS);
120
120
  }
121
121
  else {
122
122
  fileContent = body ? `${body}\n` : '';
@@ -2,9 +2,8 @@ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import fg from 'fast-glob';
4
4
  import matter from 'gray-matter';
5
- import yaml from 'js-yaml';
6
5
  import { mergeVariantProperties, resolveRef } from '../../schema/schema-refs';
7
- import { invertFieldMap } from './util';
6
+ import { dumpYaml, invertFieldMap, MATTER_OPTIONS } from './util';
8
7
  // Fields derived from the filesystem — present at validation time but not written to frontmatter
9
8
  const DERIVED_FIELDS = new Set(['title', 'content']);
10
9
  function enumPlaceholder(def) {
@@ -76,7 +75,7 @@ export function generateNewContent(nodeType, variant, schema, schemaRefRegistry,
76
75
  const toCanonicalKey = (k) => fieldMap[k] ?? k;
77
76
  const exampleWithPlaceholders = withEnumPlaceholders(example, properties, schema, schemaRefRegistry);
78
77
  const remappedExample = Object.fromEntries(Object.entries(exampleWithPlaceholders).map(([k, v]) => [toFileKey(k), v]));
79
- let frontmatterYaml = yaml.dump(remappedExample, { lineWidth: -1 }).trim();
78
+ let frontmatterYaml = dumpYaml(remappedExample, { lineWidth: -1 }).trim();
80
79
  // Append property descriptions as comments
81
80
  // YAML lines use file field names; look up properties using canonical key.
82
81
  const lines = frontmatterYaml.split('\n');
@@ -206,7 +205,7 @@ export async function templateSync(context, options) {
206
205
  continue;
207
206
  }
208
207
  const body = content.slice(fmMatch[0].length);
209
- const parsed = matter(content);
208
+ const parsed = matter(content, MATTER_OPTIONS);
210
209
  const nodeType = parsed.data.type;
211
210
  if (!nodeType) {
212
211
  continue;
@@ -1,11 +1,32 @@
1
- import type { TypeInferenceConfig } from '.';
1
+ import { type DumpOptions, type Schema } from 'js-yaml';
2
+ import type { TypeInferenceConfig } from '../../index.js';
2
3
  export declare function inferTypeFromPath(filePath: string, config: TypeInferenceConfig, knownTypes: Set<string>, typeAliases: Record<string, string> | undefined): string | undefined;
3
4
  /**
4
- * Coerce Date objects in frontmatter/YAML data to ISO date strings (YYYY-MM-DD).
5
- * gray-matter and js-yaml parse unquoted ISO dates (e.g. `date: 2026-03-31`) as
6
- * JavaScript Date objects, which are not valid JSON and fail string type validation.
5
+ * YAML schema for frontmatter and embedded YAML blocks: YAML 1.2 Core schema extended with
6
+ * YAML 1.1 merge keys (`<<`), collections (binary, omap, pairs, set), and custom Date scalar
7
+ * handling that avoids js-yaml's timestamp type. Dates and datetimes stay as the strings
8
+ * written (e.g. `2026-03-31`, `2026-06-20T22:53:05+10:00`) rather than becoming Date objects,
9
+ * which lose precision and offset and are not valid JSON.
7
10
  */
8
- export declare function coerceDates(data: Record<string, unknown>): Record<string, unknown>;
11
+ export declare const YAML_SCHEMA: Schema;
12
+ export declare function parseYaml(text: string): unknown;
13
+ export type DumpYamlOptions = DumpOptions & {
14
+ replacer?: (key: string, value: unknown) => unknown;
15
+ };
16
+ /** Dumps with YAML_SCHEMA. Date values (e.g. from non-markdown plugins) are written as ISO 8601 strings. */
17
+ export declare function dumpYaml(data: unknown, options?: DumpYamlOptions): string;
18
+ /**
19
+ * gray-matter options using YAML_SCHEMA. Passing options also bypasses gray-matter's parse cache,
20
+ * which is corrupted by a caught parse error (https://github.com/jonschlinkert/gray-matter/issues/166).
21
+ */
22
+ export declare const MATTER_OPTIONS: {
23
+ engines: {
24
+ yaml: {
25
+ parse: (text: string) => object;
26
+ stringify: (data: object) => string;
27
+ };
28
+ };
29
+ };
9
30
  /**
10
31
  * Apply field remapping to a data object.
11
32
  * Renames keys according to fieldMap (file field name → canonical field name).
@@ -1,4 +1,5 @@
1
1
  import { posix } from 'node:path';
2
+ import { binaryTag, CORE_SCHEMA, defineScalarTag, dump, load, mergeTag, omapTag, pairsTag, setTag, visit, } from 'js-yaml';
2
3
  export function inferTypeFromPath(filePath, config, knownTypes, typeAliases) {
3
4
  if (config.mode === 'off')
4
5
  return undefined;
@@ -37,26 +38,66 @@ export function inferTypeFromPath(filePath, config, knownTypes, typeAliases) {
37
38
  }
38
39
  return undefined;
39
40
  }
41
+ const dateTag = defineScalarTag('tag:yaml.org,2002:timestamp', {
42
+ identify: (data) => data instanceof Date,
43
+ represent: (data) => data.toISOString(),
44
+ resolve: (source) => source,
45
+ });
40
46
  /**
41
- * Coerce Date objects in frontmatter/YAML data to ISO date strings (YYYY-MM-DD).
42
- * gray-matter and js-yaml parse unquoted ISO dates (e.g. `date: 2026-03-31`) as
43
- * JavaScript Date objects, which are not valid JSON and fail string type validation.
47
+ * YAML schema for frontmatter and embedded YAML blocks: YAML 1.2 Core schema extended with
48
+ * YAML 1.1 merge keys (`<<`), collections (binary, omap, pairs, set), and custom Date scalar
49
+ * handling that avoids js-yaml's timestamp type. Dates and datetimes stay as the strings
50
+ * written (e.g. `2026-03-31`, `2026-06-20T22:53:05+10:00`) rather than becoming Date objects,
51
+ * which lose precision and offset and are not valid JSON.
44
52
  */
45
- export function coerceDates(data) {
46
- const result = {};
47
- for (const [key, value] of Object.entries(data)) {
48
- if (value instanceof Date) {
49
- result[key] = value.toISOString().slice(0, 10);
50
- }
51
- else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
52
- result[key] = coerceDates(value);
53
- }
54
- else {
55
- result[key] = value;
53
+ export const YAML_SCHEMA = CORE_SCHEMA.withTags(mergeTag, binaryTag, omapTag, pairsTag, setTag, dateTag);
54
+ export function parseYaml(text) {
55
+ return load(text, { schema: YAML_SCHEMA });
56
+ }
57
+ function applyReplacer(key, value, replacer) {
58
+ const replaced = replacer(key, value);
59
+ if (Array.isArray(replaced)) {
60
+ return replaced.map((item, index) => applyReplacer(String(index), item, replacer));
61
+ }
62
+ if (replaced !== null && typeof replaced === 'object' && Object.getPrototypeOf(replaced) === Object.prototype) {
63
+ const res = {};
64
+ for (const [k, v] of Object.entries(replaced)) {
65
+ const vReplaced = applyReplacer(k, v, replacer);
66
+ if (vReplaced !== undefined) {
67
+ res[k] = vReplaced;
68
+ }
56
69
  }
70
+ return res;
57
71
  }
58
- return result;
72
+ return replaced;
73
+ }
74
+ /** Dumps with YAML_SCHEMA. Date values (e.g. from non-markdown plugins) are written as ISO 8601 strings. */
75
+ export function dumpYaml(data, options) {
76
+ const input = options?.replacer ? applyReplacer('', data, options.replacer) : data;
77
+ const { replacer: _ignored, transform, ...dumpOpts } = options ?? {};
78
+ return dump(input, {
79
+ ...dumpOpts,
80
+ schema: YAML_SCHEMA,
81
+ transform: (docs) => {
82
+ visit(docs, (node) => {
83
+ if (node.kind === 'scalar' && (node.tag === '!!timestamp' || node.tag === 'tag:yaml.org,2002:timestamp')) {
84
+ node.tagged = false;
85
+ node.tag = 'tag:yaml.org,2002:str';
86
+ }
87
+ });
88
+ transform?.(docs);
89
+ },
90
+ });
59
91
  }
92
+ /**
93
+ * gray-matter options using YAML_SCHEMA. Passing options also bypasses gray-matter's parse cache,
94
+ * which is corrupted by a caught parse error (https://github.com/jonschlinkert/gray-matter/issues/166).
95
+ */
96
+ export const MATTER_OPTIONS = {
97
+ engines: {
98
+ yaml: { parse: (text) => parseYaml(text), stringify: (data) => dumpYaml(data) },
99
+ },
100
+ };
60
101
  /**
61
102
  * Apply field remapping to a data object.
62
103
  * Renames keys according to fieldMap (file field name → canonical field name).
@@ -1,4 +1,4 @@
1
- import { type RenderResult } from '../plugins/util';
1
+ import { type RenderResult } from '../plugins/util.js';
2
2
  import type { SpaceContext } from '../types';
3
3
  export declare function executeRender(formatName: string, context: SpaceContext, options: {
4
4
  filter?: string;
@@ -1,4 +1,4 @@
1
- import { shortenPluginName } from '../plugins/util';
1
+ import { shortenPluginName } from '../plugins/util.js';
2
2
  import { updateSpaceField } from '../config';
3
3
  import { assembleSpaceGraph } from '../load-space-graph';
4
4
  import { loadPlugins } from '../plugins/loader';
@@ -1,4 +1,4 @@
1
- import type { SpaceNode } from '../types';
1
+ import type { Rule, RuleViolation, SpaceNode } from '../types';
2
2
  /** Evaluation context for JSONata expressions */
3
3
  export interface EvalContext {
4
4
  /** All nodes in the space (flattened) */
@@ -12,13 +12,26 @@ export interface EvalContext {
12
12
  }
13
13
  /**
14
14
  * Evaluate a JSONata expression against a context.
15
- * Returns the result of the expression (boolean for rule checks).
15
+ * Returns the result of the expression (boolean for rule checks). Throws {@link ExpressionError} if it fails.
16
16
  *
17
17
  * @param expr - JSONata expression string
18
18
  * @param context - Evaluation context with $, $$, parent, $lookup
19
19
  * @returns Result of expression evaluation
20
20
  */
21
21
  export declare function evaluateExpression(expr: string, context: EvalContext): Promise<boolean | string | number>;
22
+ /** A JSONata expression could not be compiled or evaluated. */
23
+ export declare class ExpressionError extends Error {
24
+ readonly expression: string;
25
+ constructor(expression: string, cause: unknown);
26
+ }
27
+ /** Result of evaluating a rule's `expression` or `when`: a value, or the error that stopped it (never thrown). */
28
+ export type ExpressionOutcome = {
29
+ value: boolean | string | number;
30
+ } | {
31
+ error: string;
32
+ };
33
+ /** Like {@link evaluateExpression} but reports a failure as data, so callers can turn it into a violation. */
34
+ export declare function tryEvaluateExpression(expr: string, context: EvalContext): Promise<ExpressionOutcome>;
22
35
  /**
23
36
  * Build an evaluation context for a given node.
24
37
  *
@@ -28,3 +41,21 @@ export declare function evaluateExpression(expr: string, context: EvalContext):
28
41
  * @returns Evaluation context for the node
29
42
  */
30
43
  export declare function buildEvalContext(node: SpaceNode, allNodes: SpaceNode[], nodeIndex: Map<string, SpaceNode>): EvalContext;
44
+ /** The violation reported when a rule's `expression` or `when` cannot be evaluated: an authoring error, so always `error`. */
45
+ export declare function expressionErrorViolation(rule: Rule, file: string, error: string): RuleViolation;
46
+ /** The outcome of a rule's `expression` on one node. */
47
+ export type ExpressionRuleResult = {
48
+ passed: true;
49
+ }
50
+ /** `failed` is the expression returning something other than true; otherwise it could not be evaluated. */
51
+ | {
52
+ passed: false;
53
+ violation: RuleViolation;
54
+ failed: boolean;
55
+ };
56
+ /**
57
+ * Evaluate a rule's `expression` against a node (`file` empty and `node` a sentinel for a global rule). A failure
58
+ * reports the rule's severity; a rule with a semantic part names the expression in the evidence, so the
59
+ * violation is not mistaken for a verdict. An evaluation error reports `error` and is not a `failed` result.
60
+ */
61
+ export declare function evaluateRuleExpression(rule: Rule, node: SpaceNode, allNodes: SpaceNode[], nodeIndex: Map<string, SpaceNode>, file?: string): Promise<ExpressionRuleResult>;