structured-context 0.13.1 → 0.15.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 (64) hide show
  1. package/README.md +5 -3
  2. package/dist/api.d.ts +3 -2
  3. package/dist/api.js +3 -2
  4. package/dist/commands/dump.js +1 -1
  5. package/dist/commands/spaces.d.ts +5 -0
  6. package/dist/commands/spaces.js +46 -10
  7. package/dist/commands/validate.js +8 -8
  8. package/dist/config/health.d.ts +31 -0
  9. package/dist/config/health.js +86 -0
  10. package/dist/{config.d.ts → config/index.d.ts} +18 -3
  11. package/dist/{config.js → config/index.js} +76 -37
  12. package/dist/config/write.d.ts +27 -0
  13. package/dist/config/write.js +91 -0
  14. package/dist/constants.d.ts +2 -0
  15. package/dist/constants.js +2 -0
  16. package/dist/errors.d.ts +9 -0
  17. package/dist/errors.js +8 -0
  18. package/dist/filter/augment-nodes.d.ts +5 -4
  19. package/dist/filter/augment-nodes.js +20 -19
  20. package/dist/filter/expand-include.d.ts +2 -2
  21. package/dist/filter/expand-include.js +15 -17
  22. package/dist/filter/filter-nodes.js +2 -2
  23. package/dist/index.js +13 -2
  24. package/dist/judge/context.js +12 -12
  25. package/dist/judge/judge-node.d.ts +3 -3
  26. package/dist/judge/judge-node.js +7 -7
  27. package/dist/judge/prompt.js +3 -3
  28. package/dist/judge/provider/fake.d.ts +1 -1
  29. package/dist/judge/provider/fake.js +3 -6
  30. package/dist/judge/provider/jev.js +1 -1
  31. package/dist/judge/provider/openai-decisions.js +1 -1
  32. package/dist/judge/types.d.ts +4 -1
  33. package/dist/plugins/markdown/index.d.ts +6 -0
  34. package/dist/plugins/markdown/index.js +1 -0
  35. package/dist/plugins/markdown/parse-embedded.d.ts +7 -2
  36. package/dist/plugins/markdown/parse-embedded.js +23 -21
  37. package/dist/plugins/markdown/read-space.js +10 -8
  38. package/dist/plugins/markdown/render-bullets.js +3 -3
  39. package/dist/plugins/markdown/util.d.ts +11 -0
  40. package/dist/plugins/markdown/util.js +21 -0
  41. package/dist/plugins/mermaid/render-mermaid.js +5 -6
  42. package/dist/plugins/miro/cache.js +1 -1
  43. package/dist/plugins/miro/sync.js +3 -2
  44. package/dist/read/resolve-graph-edges.js +12 -18
  45. package/dist/schema/evaluate-rule.d.ts +4 -4
  46. package/dist/schema/evaluate-rule.js +14 -10
  47. package/dist/schema/gating.d.ts +10 -10
  48. package/dist/schema/gating.js +45 -44
  49. package/dist/schema/validate-graph.js +8 -11
  50. package/dist/schema/validate-rules.d.ts +4 -4
  51. package/dist/schema/validate-rules.js +15 -17
  52. package/dist/schemas/_ost_strict.json +1 -1
  53. package/dist/space-graph.d.ts +9 -9
  54. package/dist/space-graph.js +16 -16
  55. package/dist/testing.d.ts +1 -1
  56. package/dist/testing.js +3 -3
  57. package/dist/types.d.ts +14 -10
  58. package/dist/validate.d.ts +2 -2
  59. package/dist/validate.js +18 -19
  60. package/docs/concepts.md +26 -8
  61. package/docs/config.md +31 -6
  62. package/docs/rules.md +17 -13
  63. package/package.json +4 -4
  64. package/schemas/_ost_strict.json +1 -1
@@ -42,7 +42,7 @@ function appendParentField(parentNode, field, linkRef) {
42
42
  fieldValue.push(linkRef);
43
43
  }
44
44
  else {
45
- throw new Error(`Cannot append child link to field '${field}' on node '${parentNode.label}': ` +
45
+ throw new Error(`Cannot append child link to field '${field}' on node '${parentNode.id}': ` +
46
46
  `field exists but is not an array (found ${typeof fieldValue}). ` +
47
47
  `Child link: ${linkRef}`);
48
48
  }
@@ -219,7 +219,7 @@ function isFieldOnlyLine(line) {
219
219
  return hasBracketed;
220
220
  return /^[a-zA-Z][a-zA-Z0-9_-]*:: *.*$/.test(cleanText);
221
221
  }
222
- function processListItem(item, parentRef, contentTarget, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, parentFieldAppend, activeNodeFieldAppend, file, lineOffset, walk) {
222
+ function processListItem(item, parentRef, contentTarget, nodes, makeId, buildLinkTargets, typeAliases, fieldMap, pendingType, parentFieldAppend, activeNodeFieldAppend, file, lineOffset, walk) {
223
223
  const itemEnd = offsetsOf(item).end;
224
224
  const firstPara = item.children.find((c) => c.type === 'paragraph');
225
225
  if (!firstPara) {
@@ -270,7 +270,7 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
270
270
  : undefined;
271
271
  const linkTargets = buildLinkTargets(title);
272
272
  const newNode = {
273
- label: makeLabel(title),
273
+ id: makeId(title),
274
274
  title,
275
275
  schemaData,
276
276
  linkTargets,
@@ -294,7 +294,7 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
294
294
  continue;
295
295
  if (child.type === 'list') {
296
296
  for (const subItem of child.children) {
297
- processListItem(subItem, nestedParentRef, newNode, nodes, makeLabel, buildLinkTargets, typeAliases, fieldMap, pendingType, undefined, undefined, file, lineOffset, walk);
297
+ processListItem(subItem, nestedParentRef, newNode, nodes, makeId, buildLinkTargets, typeAliases, fieldMap, pendingType, undefined, undefined, file, lineOffset, walk);
298
298
  }
299
299
  }
300
300
  else {
@@ -316,7 +316,7 @@ function processListItem(item, parentRef, contentTarget, nodes, makeLabel, build
316
316
  * (directory) to find embedded sub-nodes within a page's content.
317
317
  */
318
318
  export function extractEmbeddedNodes(body, options) {
319
- const { pageTitle, pageType, metadata, fieldMap } = options;
319
+ const { page, pageType, metadata, fieldMap } = options;
320
320
  const levels = metadata.hierarchy?.levels ?? [];
321
321
  const hierarchy = levels.map((l) => l.type);
322
322
  const relationships = metadata.relationships ?? [];
@@ -325,7 +325,7 @@ export function extractEmbeddedNodes(body, options) {
325
325
  const nodes = [];
326
326
  // Preamble/root content sink - never added to nodes
327
327
  const rootNode = {
328
- label: '_root_',
328
+ id: '_root_',
329
329
  title: '_root_',
330
330
  schemaData: { type: 'space_on_a_page' },
331
331
  linkTargets: [],
@@ -335,8 +335,8 @@ export function extractEmbeddedNodes(body, options) {
335
335
  const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
336
336
  // In typed-page mode: stack starts with the page's own virtual entry (depth 0).
337
337
  // In space_on_a_page mode: stack starts empty (first heading has no parent).
338
- const stack = !isOnAPageMode && pageTitle !== undefined
339
- ? [{ depth: 0, title: pageTitle, nodeType: pageType, refTarget: pageTitle }]
338
+ const stack = !isOnAPageMode && page !== undefined
339
+ ? [{ depth: 0, title: page.targets[0] ?? page.id, nodeType: pageType, refTarget: page.targets[0] ?? page.id }]
340
340
  : [];
341
341
  let parseState = isOnAPageMode ? 'preamble' : 'active';
342
342
  let preambleNodeCount = 0;
@@ -442,8 +442,8 @@ export function extractEmbeddedNodes(body, options) {
442
442
  }
443
443
  return undefined;
444
444
  }
445
- function makeLabel(title) {
446
- return title;
445
+ function makeId(title) {
446
+ return (page ? `${page.id}#${title}` : title);
447
447
  }
448
448
  /**
449
449
  * Walk the stack backwards to find the deepest typed node entry (nodeType !== '').
@@ -486,24 +486,26 @@ export function extractEmbeddedNodes(body, options) {
486
486
  return g.headingNode;
487
487
  }
488
488
  function buildHeadingLinkTargets(rawHeadingText, title, anchor) {
489
- if (!pageTitle) {
489
+ if (!page) {
490
490
  return [title];
491
491
  }
492
492
  const targets = [];
493
493
  const sectionTarget = normalizeHeadingSectionTarget(rawHeadingText);
494
494
  if (sectionTarget) {
495
- targets.push(`${pageTitle}#${sectionTarget}`);
495
+ for (const pageTarget of page.targets)
496
+ targets.push(`${pageTarget}#${sectionTarget}`);
496
497
  }
497
498
  if (anchor) {
498
- targets.push(`${pageTitle}#^${anchor}`);
499
+ for (const pageTarget of page.targets)
500
+ targets.push(`${pageTarget}#^${anchor}`);
499
501
  }
500
502
  return targets.length > 0 ? targets : [title];
501
503
  }
502
504
  function buildListItemLinkTargets(title) {
503
- if (!pageTitle)
505
+ if (!page)
504
506
  return [title];
505
507
  const normalized = normalizeHeadingSectionTarget(title);
506
- return normalized ? [`${pageTitle}#${normalized}`] : [title];
508
+ return normalized ? page.targets.map((pageTarget) => `${pageTarget}#${normalized}`) : [title];
507
509
  }
508
510
  let grouping = null;
509
511
  let activeNode = rootNode;
@@ -604,7 +606,7 @@ export function extractEmbeddedNodes(body, options) {
604
606
  : undefined;
605
607
  const linkTargets = buildHeadingLinkTargets(rawText, title, anchor);
606
608
  const headingNode = {
607
- label: makeLabel(title),
609
+ id: makeId(title),
608
610
  title,
609
611
  schemaData,
610
612
  linkTargets,
@@ -655,14 +657,14 @@ export function extractEmbeddedNodes(body, options) {
655
657
  : undefined;
656
658
  const walk = { claims, pos: spanStart };
657
659
  for (const item of list.children) {
658
- processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg, options.file, options.lineOffset, walk);
660
+ processListItem(item, isParentSide ? undefined : semanticParent.ref, grouping.headingNode, nodes, makeId, buildListItemLinkTargets, typeAliases, fieldMap, definition.type, parentFieldAppendArg, activeNodeFieldAppendArg, options.file, options.lineOffset, walk);
659
661
  }
660
662
  grouping = null;
661
663
  }
662
664
  else {
663
665
  const walk = { claims, pos: spanStart };
664
666
  for (const item of list.children) {
665
- processListItem(item, parentRef, activeNode, nodes, makeLabel, buildListItemLinkTargets, typeAliases, fieldMap, undefined, undefined, undefined, options.file, options.lineOffset, walk);
667
+ processListItem(item, parentRef, activeNode, nodes, makeId, buildListItemLinkTargets, typeAliases, fieldMap, undefined, undefined, undefined, options.file, options.lineOffset, walk);
666
668
  }
667
669
  }
668
670
  }
@@ -755,7 +757,7 @@ export function extractEmbeddedNodes(body, options) {
755
757
  : undefined;
756
758
  const linkTargets = buildListItemLinkTargets(title);
757
759
  const rowNode = {
758
- label: makeLabel(title),
760
+ id: makeId(title),
759
761
  title,
760
762
  schemaData,
761
763
  linkTargets,
@@ -788,7 +790,7 @@ export function extractEmbeddedNodes(body, options) {
788
790
  const { fields: unbracketedFields } = extractUnbracketedFields(afterBracketed);
789
791
  const allFields = applyFieldMap({ ...unbracketedFields, ...bracketedFields }, fieldMap);
790
792
  if ('type' in allFields) {
791
- throw new Error(`Type override via paragraph field is not supported at "${activeNode.schemaData.title ?? activeNode.label}". ` +
793
+ throw new Error(`Type override via paragraph field is not supported at "${activeNode.schemaData.title ?? activeNode.id}". ` +
792
794
  `Put [type:: ${allFields.type}] directly in the heading text.`);
793
795
  }
794
796
  Object.assign(activeNode.schemaData, allFields);
@@ -817,7 +819,7 @@ export function extractEmbeddedNodes(body, options) {
817
819
  consumeBlock(activeNode);
818
820
  }
819
821
  else if (Array.isArray(parsed)) {
820
- throw new Error(`YAML block must be an object at "${activeNode.label}".`);
822
+ throw new Error(`YAML block must be an object at "${activeNode.id}".`);
821
823
  }
822
824
  else {
823
825
  claimBlock(activeNode);
@@ -5,7 +5,7 @@ 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, inferTypeFromPath, MATTER_OPTIONS } from './util';
8
+ import { applyFieldMap, defaultPageTitle, inferTypeFromPath, MATTER_OPTIONS, pathLinkTargets } from './util';
9
9
  export function readSpaceOnAPage(context) {
10
10
  const { space, resolvedSchemaPath, schema: { metadata }, } = context;
11
11
  const filePath = resolve(space.path);
@@ -20,12 +20,11 @@ export function readSpaceOnAPage(context) {
20
20
  if (!hierarchyLevels || hierarchyLevels.length === 0) {
21
21
  throw new Error(`Schema at ${resolvedSchemaPath} must define "$metadata.hierarchy.levels" to read a space_on_a_page file.`);
22
22
  }
23
- const pageTitle = basename(filePath, '.md');
24
23
  const fmMatch = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
25
24
  const lineOffset = fmMatch ? (fmMatch[0].match(/\n/g) || []).length : 0;
26
25
  const fileName = basename(filePath);
27
26
  const { nodes, preambleNodeCount, terminatedHeadings } = extractEmbeddedNodes(body, {
28
- pageTitle,
27
+ page: { id: fileName, targets: pathLinkTargets(fileName) },
29
28
  pageType: 'space_on_a_page',
30
29
  metadata,
31
30
  file: fileName,
@@ -98,15 +97,18 @@ export async function readSpaceDirectory(context, options) {
98
97
  continue;
99
98
  }
100
99
  const pageType = data.type;
101
- const fileBase = basename(file, '.md');
102
- const title = data.title ?? fileBase;
100
+ // A non-string title (e.g. `title: 42`) isn't a usable name or link target; schema validation reports it.
101
+ const explicitTitle = typeof data.title === 'string' ? data.title : undefined;
102
+ const title = explicitTitle ?? defaultPageTitle(file, mdCfg.folderIndex);
103
+ const pathTargets = pathLinkTargets(file);
103
104
  const fmMatch = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
104
105
  const lineOffset = fmMatch ? (fmMatch[0].match(/\n/g) || []).length : 0;
105
106
  const pageNode = {
106
- label: file,
107
+ id: file,
107
108
  title,
108
109
  schemaData: { title, ...data },
109
- linkTargets: [title, fileBase],
110
+ // A title derived from a folder index is display only, never a link target.
111
+ linkTargets: [...new Set([...(explicitTitle !== undefined ? [explicitTitle] : []), ...pathTargets])],
110
112
  type: pageType,
111
113
  source: { file, line: 1 },
112
114
  ...(data.content !== undefined ? { content: data.content } : {}),
@@ -114,7 +116,7 @@ export async function readSpaceDirectory(context, options) {
114
116
  };
115
117
  if (!ON_A_PAGE_TYPES.includes(pageType)) {
116
118
  const { nodes: embedded, preambleContent, preambleFields, terminatedHeadings, } = extractEmbeddedNodes(parsed.content, {
117
- pageTitle: fileBase,
119
+ page: { id: file, targets: pathTargets },
118
120
  pageType,
119
121
  metadata,
120
122
  fieldMap,
@@ -6,14 +6,14 @@ export function renderBullets(graph) {
6
6
  const indent = ' '.repeat(depth);
7
7
  const type = node.resolvedType;
8
8
  const title = node.title;
9
- const nodeChildren = children.get(title) ?? [];
10
- if (seen.has(title)) {
9
+ const nodeChildren = children.get(node.id) ?? [];
10
+ if (seen.has(node.id)) {
11
11
  if (nodeChildren.length > 0) {
12
12
  lines.push(`${indent}- ${type}: ${title} (*)`);
13
13
  }
14
14
  return;
15
15
  }
16
- seen.add(title);
16
+ seen.add(node.id);
17
17
  lines.push(`${indent}- ${type}: ${title}`);
18
18
  for (const child of nodeChildren) {
19
19
  renderNode(child, depth + 1);
@@ -38,3 +38,14 @@ export declare function applyFieldMap(data: Record<string, unknown>, fieldMap: R
38
38
  * Used for write operations (e.g. template-sync) to translate back to file field names.
39
39
  */
40
40
  export declare function invertFieldMap(fieldMap: Record<string, string>): Record<string, string>;
41
+ /**
42
+ * Link targets for a page, most specific first: the space-relative path without `.md` and every shorter
43
+ * path suffix down to the basename. Obsidian writes these when a bare name is ambiguous.
44
+ */
45
+ export declare function pathLinkTargets(file: string): string[];
46
+ /**
47
+ * Display title for a page without an explicit `title`: the containing folder's name when the file is a
48
+ * folder index (its basename is listed in `folderIndex`, case-insensitively), otherwise the basename.
49
+ * An index at the space root has no folder to name it and keeps its basename.
50
+ */
51
+ export declare function defaultPageTitle(file: string, folderIndex: string[] | undefined): string;
@@ -119,3 +119,24 @@ export function applyFieldMap(data, fieldMap) {
119
119
  export function invertFieldMap(fieldMap) {
120
120
  return Object.fromEntries(Object.entries(fieldMap).map(([src, canonical]) => [canonical, src]));
121
121
  }
122
+ /**
123
+ * Link targets for a page, most specific first: the space-relative path without `.md` and every shorter
124
+ * path suffix down to the basename. Obsidian writes these when a bare name is ambiguous.
125
+ */
126
+ export function pathLinkTargets(file) {
127
+ const segments = file.replace(/\\/g, '/').replace(/\.md$/i, '').split('/');
128
+ return segments.map((_, i) => segments.slice(i).join('/'));
129
+ }
130
+ /**
131
+ * Display title for a page without an explicit `title`: the containing folder's name when the file is a
132
+ * folder index (its basename is listed in `folderIndex`, case-insensitively), otherwise the basename.
133
+ * An index at the space root has no folder to name it and keeps its basename.
134
+ */
135
+ export function defaultPageTitle(file, folderIndex) {
136
+ const segments = file.replace(/\\/g, '/').replace(/\.md$/i, '').split('/');
137
+ const base = segments[segments.length - 1];
138
+ const folder = segments[segments.length - 2];
139
+ if (folder !== undefined && folderIndex?.some((name) => name.toLowerCase() === base.toLowerCase()))
140
+ return folder;
141
+ return base;
142
+ }
@@ -15,7 +15,7 @@ function safeNodeId(id) {
15
15
  return id.replace(/[^a-zA-Z0-9_-]/g, '_');
16
16
  }
17
17
  export function renderMermaid(graph) {
18
- const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyTitles: hierarchyNodeSet } = graph;
18
+ const { hierarchyRoots, orphans, hierarchyChildren: children, hierarchyIds: hierarchyNodeSet } = graph;
19
19
  const levels = graph.hierarchy?.levels ?? [];
20
20
  let mmd = 'graph TD\n';
21
21
  for (let i = 0; i < levels.length; i++) {
@@ -25,21 +25,20 @@ export function renderMermaid(graph) {
25
25
  }
26
26
  const addedNodes = new Set();
27
27
  function addNodeAndChildren(node) {
28
- const nodeId = node.title;
28
+ const nodeId = node.id;
29
29
  if (addedNodes.has(nodeId))
30
30
  return;
31
31
  addedNodes.add(nodeId);
32
32
  const type = node.resolvedType;
33
33
  const priority = node.schemaData.priority;
34
- const label = priority ? `${nodeId} (${priority})` : nodeId;
34
+ const label = priority ? `${node.title} (${priority})` : node.title;
35
35
  const safeId = safeNodeId(nodeId);
36
36
  const escapedLabel = escapeMermaidString(label);
37
37
  mmd += ` ${safeId}["${escapedLabel}"]:::${type}\n`;
38
38
  const nodeChildren = children.get(nodeId) ?? [];
39
39
  for (const child of nodeChildren) {
40
- const childTitle = child.title;
41
- if (hierarchyNodeSet.has(childTitle)) {
42
- const safeChildId = safeNodeId(childTitle);
40
+ if (hierarchyNodeSet.has(child.id)) {
41
+ const safeChildId = safeNodeId(child.id);
43
42
  mmd += ` ${safeId} --> ${safeChildId}\n`;
44
43
  addNodeAndChildren(child);
45
44
  }
@@ -30,7 +30,7 @@ export function computeNodeHash(node) {
30
30
  status: node.schemaData.status,
31
31
  summary: node.schemaData.summary,
32
32
  priority: node.schemaData.priority,
33
- parents: node.resolvedParents.filter((r) => r.source === 'hierarchy').map((r) => r.title),
33
+ parents: node.resolvedParents.filter((r) => r.source === 'hierarchy').map((r) => r.id),
34
34
  };
35
35
  return createHash('sha256').update(JSON.stringify(relevant)).digest('hex').slice(0, 16);
36
36
  }
@@ -17,7 +17,7 @@ export async function miroSync(context, graph, options) {
17
17
  throw new Error('No frameId in miro plugin config. Pass --new-frame "Title" to create one.');
18
18
  // 3. Filter to hierarchy nodes only (graph already built by caller)
19
19
  const levels = graph.hierarchy?.levels ?? [];
20
- const nodes = [...graph.nodes.values()].filter((n) => graph.hierarchyTitles.has(n.title));
20
+ const nodes = [...graph.nodes.values()].filter((n) => graph.hierarchyIds.has(n.id));
21
21
  if (nodes.length === 0) {
22
22
  return 'No space nodes found.';
23
23
  }
@@ -257,9 +257,10 @@ export async function miroSync(context, graph, options) {
257
257
  const desiredEdges = new Map();
258
258
  for (const node of nodes) {
259
259
  const childTitle = node.title;
260
- for (const { title: parentTitle, source, hierarchy } of node.resolvedParents) {
260
+ for (const { id: parentId, source, hierarchy } of node.resolvedParents) {
261
261
  if (source !== 'hierarchy' || hierarchy !== graph.hierarchy?.name)
262
262
  continue;
263
+ const parentTitle = graph.nodes.get(parentId)?.title ?? parentId;
263
264
  // Both endpoints must have verified cards on the board
264
265
  if (verifiedCardIds.has(parentTitle) && verifiedCardIds.has(childTitle)) {
265
266
  const key = `${parentTitle}\u2192${childTitle}`;
@@ -31,11 +31,11 @@ function resolveEdge(nodesByType, targetIndex, edge, source, hierarchy, selfRef,
31
31
  const { type: rawChildType, parent: rawParentType, field, fieldOn, multiple } = edge;
32
32
  const childType = resolveNodeType(rawChildType, typeAliases);
33
33
  const parentType = resolveNodeType(rawParentType, typeAliases);
34
- function pushParentRef(childNode, parentTitle) {
35
- // Deduplicate by (field, title) — same parent via different fields is intentional
36
- if (!childNode.resolvedParents.some((r) => r.field === field && r.title === parentTitle)) {
34
+ function pushParentRef(childNode, parentId) {
35
+ // Deduplicate by (field, id) — same parent via different fields is intentional
36
+ if (!childNode.resolvedParents.some((r) => r.field === field && r.id === parentId)) {
37
37
  childNode.resolvedParents.push({
38
- title: parentTitle,
38
+ id: parentId,
39
39
  field,
40
40
  fieldOn,
41
41
  source,
@@ -47,7 +47,7 @@ function resolveEdge(nodesByType, targetIndex, edge, source, hierarchy, selfRef,
47
47
  function checkShape(ownerNode, rawField) {
48
48
  if (multiple && !Array.isArray(rawField)) {
49
49
  unresolvedRefs.push({
50
- label: ownerNode.label,
50
+ id: ownerNode.id,
51
51
  ref: String(rawField),
52
52
  field,
53
53
  reason: 'invalid_shape',
@@ -57,7 +57,7 @@ function resolveEdge(nodesByType, targetIndex, edge, source, hierarchy, selfRef,
57
57
  }
58
58
  if (!multiple && typeof rawField !== 'string') {
59
59
  unresolvedRefs.push({
60
- label: ownerNode.label,
60
+ id: ownerNode.id,
61
61
  ref: String(rawField),
62
62
  field,
63
63
  reason: 'invalid_shape',
@@ -73,7 +73,7 @@ function resolveEdge(nodesByType, targetIndex, edge, source, hierarchy, selfRef,
73
73
  const resolved = targetIndex.get(target);
74
74
  if (resolved === undefined) {
75
75
  unresolvedRefs.push({
76
- label: ownerNode.label,
76
+ id: ownerNode.id,
77
77
  ref,
78
78
  field,
79
79
  reason: 'not_found',
@@ -84,7 +84,7 @@ function resolveEdge(nodesByType, targetIndex, edge, source, hierarchy, selfRef,
84
84
  }
85
85
  if (resolved === null) {
86
86
  unresolvedRefs.push({
87
- label: ownerNode.label,
87
+ id: ownerNode.id,
88
88
  ref,
89
89
  field,
90
90
  reason: 'ambiguous',
@@ -109,10 +109,7 @@ function resolveEdge(nodesByType, targetIndex, edge, source, hierarchy, selfRef,
109
109
  const childNode = checkAndRecordRef(parentNode, ref);
110
110
  if (!childNode)
111
111
  continue;
112
- const parentTitle = parentNode.schemaData.title;
113
- if (typeof parentTitle !== 'string')
114
- continue;
115
- pushParentRef(childNode, parentTitle);
112
+ pushParentRef(childNode, parentNode.id);
116
113
  }
117
114
  }
118
115
  }
@@ -129,10 +126,7 @@ function resolveEdge(nodesByType, targetIndex, edge, source, hierarchy, selfRef,
129
126
  const parentNode = checkAndRecordRef(childNode, ref);
130
127
  if (!parentNode)
131
128
  continue;
132
- const parentTitle = parentNode.schemaData.title;
133
- if (typeof parentTitle !== 'string')
134
- continue;
135
- pushParentRef(childNode, parentTitle);
129
+ pushParentRef(childNode, parentNode.id);
136
130
  }
137
131
  }
138
132
  }
@@ -237,12 +231,12 @@ export function resolveGraphEdges(nodes, metadata) {
237
231
  for (const node of spaceNodes) {
238
232
  node.resolvedLinks = (node.contentLinks ?? []).map((link) => classifyContentLink(link, targetIndex));
239
233
  }
240
- // Deduplicate by (label, field, ref) — the same broken link may be encountered across
234
+ // Deduplicate by (id, field, ref) — the same broken link may be encountered across
241
235
  // multiple resolveEdge calls (e.g. selfRef + regular hierarchy share the same field).
242
236
  const seen = new Set();
243
237
  const deduped = [];
244
238
  for (const u of unresolvedRefs) {
245
- const key = `${u.label}\0${u.field}\0${u.ref}`;
239
+ const key = `${u.id}\0${u.field}\0${u.ref}`;
246
240
  if (!seen.has(key)) {
247
241
  seen.add(key);
248
242
  deduped.push(u);
@@ -1,4 +1,4 @@
1
- import type { Rule, RuleViolation, SpaceNode } from '../types';
1
+ import type { NodeId, 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) */
@@ -37,10 +37,10 @@ export declare function tryEvaluateExpression(expr: string, context: EvalContext
37
37
  *
38
38
  * @param node - The node to build context for
39
39
  * @param allNodes - All nodes in the space
40
- * @param nodeIndex - Map of node titles to nodes for efficient lookup
40
+ * @param nodeIndex - Map of node ids to nodes for efficient lookup
41
41
  * @returns Evaluation context for the node
42
42
  */
43
- export declare function buildEvalContext(node: SpaceNode, allNodes: SpaceNode[], nodeIndex: Map<string, SpaceNode>): EvalContext;
43
+ export declare function buildEvalContext(node: SpaceNode, allNodes: SpaceNode[], nodeIndex: Map<NodeId, SpaceNode>): EvalContext;
44
44
  /** The violation reported when a rule's `expression` or `when` cannot be evaluated: an authoring error, so always `error`. */
45
45
  export declare function expressionErrorViolation(rule: Rule, file: string, error: string): RuleViolation;
46
46
  /** The outcome of a rule's `expression` on one node. */
@@ -58,4 +58,4 @@ export type ExpressionRuleResult = {
58
58
  * reports the rule's severity; a rule with a semantic part names the expression in the evidence, so the
59
59
  * violation is not mistaken for a verdict. An evaluation error reports `error` and is not a `failed` result.
60
60
  */
61
- export declare function evaluateRuleExpression(rule: Rule, node: SpaceNode, allNodes: SpaceNode[], nodeIndex: Map<string, SpaceNode>, file?: string): Promise<ExpressionRuleResult>;
61
+ export declare function evaluateRuleExpression(rule: Rule, node: SpaceNode, allNodes: SpaceNode[], nodeIndex: Map<NodeId, SpaceNode>, file?: string): Promise<ExpressionRuleResult>;
@@ -64,12 +64,16 @@ export async function tryEvaluateExpression(expr, context) {
64
64
  * @param node - The node to flatten
65
65
  * @returns Flattened node with properties directly accessible
66
66
  */
67
- function flattenNode(node) {
67
+ function flattenNode(node, nodeIndex) {
68
+ const parentTitle = (r) => nodeIndex.get(r.id)?.title ?? r.id;
68
69
  return {
69
70
  ...node.schemaData,
71
+ id: node.id,
70
72
  resolvedType: node.resolvedType,
71
- resolvedParentTitle: node.resolvedParents[0]?.title, // first parent or undefined, provided for convenience
72
- resolvedParentTitles: node.resolvedParents.map((r) => r.title), // full array of parent titles
73
+ resolvedParentId: node.resolvedParents[0]?.id, // first parent or undefined, provided for convenience
74
+ resolvedParentIds: node.resolvedParents.map((r) => r.id), // full array of parent ids
75
+ resolvedParentTitle: node.resolvedParents[0] && parentTitle(node.resolvedParents[0]), // display title of the first parent
76
+ resolvedParentTitles: node.resolvedParents.map(parentTitle), // display titles of all parents
73
77
  resolvedLinks: node.resolvedLinks,
74
78
  };
75
79
  }
@@ -78,23 +82,23 @@ function flattenNode(node) {
78
82
  *
79
83
  * @param node - The node to build context for
80
84
  * @param allNodes - All nodes in the space
81
- * @param nodeIndex - Map of node titles to nodes for efficient lookup
85
+ * @param nodeIndex - Map of node ids to nodes for efficient lookup
82
86
  * @returns Evaluation context for the node
83
87
  */
84
88
  export function buildEvalContext(node, allNodes, nodeIndex) {
85
89
  // Flatten all nodes for JSONata access
86
- const flattenedNodes = allNodes.map(flattenNode);
90
+ const flattenedNodes = allNodes.map((n) => flattenNode(n, nodeIndex));
87
91
  // Build all parent objects from resolvedParents array
88
92
  const flattenedParents = [];
89
- for (const { title: parentTitle } of node.resolvedParents) {
90
- const parentNode = nodeIndex.get(parentTitle);
93
+ for (const { id: parentId } of node.resolvedParents) {
94
+ const parentNode = nodeIndex.get(parentId);
91
95
  if (parentNode) {
92
- flattenedParents.push(flattenNode(parentNode));
96
+ flattenedParents.push(flattenNode(parentNode, nodeIndex));
93
97
  }
94
98
  }
95
99
  return {
96
100
  nodes: flattenedNodes,
97
- $$: flattenNode(node),
101
+ $$: flattenNode(node, nodeIndex),
98
102
  parent: flattenedParents[0],
99
103
  parents: flattenedParents.length > 0 ? flattenedParents : undefined,
100
104
  };
@@ -114,7 +118,7 @@ export function expressionErrorViolation(rule, file, error) {
114
118
  * reports the rule's severity; a rule with a semantic part names the expression in the evidence, so the
115
119
  * violation is not mistaken for a verdict. An evaluation error reports `error` and is not a `failed` result.
116
120
  */
117
- export async function evaluateRuleExpression(rule, node, allNodes, nodeIndex, file = node.label) {
121
+ export async function evaluateRuleExpression(rule, node, allNodes, nodeIndex, file = node.id) {
118
122
  const expression = rule.expression;
119
123
  const outcome = await tryEvaluateExpression(expression, buildEvalContext(node, allNodes, nodeIndex));
120
124
  if ('error' in outcome) {
@@ -1,10 +1,10 @@
1
1
  import type { TrimReport } from '../judge/trim';
2
2
  import type { SemanticJudgeProvider } from '../judge/types';
3
3
  import type { SpaceGraph } from '../space-graph';
4
- import type { Rule, RuleSeverity, RuleViolation, SpaceNode } from '../types';
4
+ import type { NodeId, Rule, RuleSeverity, RuleViolation, SpaceNode } from '../types';
5
5
  /**
6
- * Whether a failure of this rule gates its subtree: explicit `gatesDescendants`, else true only for `error` severity.
7
- * `severity` is that of the failure: the rule's own for an expression, the verdict's for a semantic part.
6
+ * Whether a violation of this rule gates its subtree: explicit `gatesDescendants`, else true only for `error` severity.
7
+ * `severity` is that of the violation: the rule's own for an expression, the verdict's for a semantic part.
8
8
  */
9
9
  export declare function ruleGatesDescendants(rule: Rule, severity?: RuleSeverity): boolean;
10
10
  /**
@@ -12,10 +12,10 @@ export declare function ruleGatesDescendants(rule: Rule, severity?: RuleSeverity
12
12
  * (schema validation or hierarchy structure) that failed on a specific node.
13
13
  */
14
14
  export type GatingSeed = {
15
- /** Label (file) of the failing node. */
15
+ /** Id (file) of the failing node. */
16
16
  file: string;
17
- /** Title of the failing node. */
18
- title: string;
17
+ /** Id of the failing node. */
18
+ id: NodeId;
19
19
  /** Which always-run layer produced the failure. */
20
20
  reason: 'schema' | 'hierarchy';
21
21
  };
@@ -24,10 +24,10 @@ export type GatingSeed = {
24
24
  * rule evaluation for itself (if schema/hierarchy caused it) and/or its hierarchy subtree.
25
25
  */
26
26
  export type CoverageGate = {
27
- /** Label (file) of the node whose failure triggered this gate. */
27
+ /** Id (file) of the node whose failure triggered this gate. */
28
28
  file: string;
29
- /** Title of the blocking node. */
30
- nodeTitle: string;
29
+ /** Id of the blocking node. */
30
+ nodeId: NodeId;
31
31
  /** What kind of check triggered the gate. */
32
32
  reason: 'schema' | 'hierarchy' | 'rule';
33
33
  /** Rule id responsible, when `reason` is `'rule'`. */
@@ -56,7 +56,7 @@ export interface GatingOptions {
56
56
  * Compute breadth-first depth (1-based, from hierarchy roots and orphans) for every
57
57
  * hierarchy-typed node in the graph. Non-hierarchy nodes are not included.
58
58
  */
59
- export declare function computeHierarchyDepths(graph: SpaceGraph): Map<string, number>;
59
+ export declare function computeHierarchyDepths(graph: SpaceGraph): Map<NodeId, number>;
60
60
  /**
61
61
  * Evaluate executable rules with cascade gating: a schema, hierarchy, or blocking-rule
62
62
  * failure on a node suppresses rule evaluation for that node (when the failure is