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.
- package/README.md +5 -3
- package/dist/api.d.ts +3 -2
- package/dist/api.js +3 -2
- package/dist/commands/dump.js +1 -1
- package/dist/commands/spaces.d.ts +5 -0
- package/dist/commands/spaces.js +46 -10
- package/dist/commands/validate.js +8 -8
- package/dist/config/health.d.ts +31 -0
- package/dist/config/health.js +86 -0
- package/dist/{config.d.ts → config/index.d.ts} +18 -3
- package/dist/{config.js → config/index.js} +76 -37
- package/dist/config/write.d.ts +27 -0
- package/dist/config/write.js +91 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +8 -0
- package/dist/filter/augment-nodes.d.ts +5 -4
- package/dist/filter/augment-nodes.js +20 -19
- package/dist/filter/expand-include.d.ts +2 -2
- package/dist/filter/expand-include.js +15 -17
- package/dist/filter/filter-nodes.js +2 -2
- package/dist/index.js +13 -2
- package/dist/judge/context.js +12 -12
- package/dist/judge/judge-node.d.ts +3 -3
- package/dist/judge/judge-node.js +7 -7
- package/dist/judge/prompt.js +3 -3
- package/dist/judge/provider/fake.d.ts +1 -1
- package/dist/judge/provider/fake.js +3 -6
- package/dist/judge/provider/jev.js +1 -1
- package/dist/judge/provider/openai-decisions.js +1 -1
- package/dist/judge/types.d.ts +4 -1
- package/dist/plugins/markdown/index.d.ts +6 -0
- package/dist/plugins/markdown/index.js +1 -0
- package/dist/plugins/markdown/parse-embedded.d.ts +7 -2
- package/dist/plugins/markdown/parse-embedded.js +23 -21
- package/dist/plugins/markdown/read-space.js +10 -8
- package/dist/plugins/markdown/render-bullets.js +3 -3
- package/dist/plugins/markdown/util.d.ts +11 -0
- package/dist/plugins/markdown/util.js +21 -0
- package/dist/plugins/mermaid/render-mermaid.js +5 -6
- package/dist/plugins/miro/cache.js +1 -1
- package/dist/plugins/miro/sync.js +3 -2
- package/dist/read/resolve-graph-edges.js +12 -18
- package/dist/schema/evaluate-rule.d.ts +4 -4
- package/dist/schema/evaluate-rule.js +14 -10
- package/dist/schema/gating.d.ts +10 -10
- package/dist/schema/gating.js +45 -44
- package/dist/schema/validate-graph.js +8 -11
- package/dist/schema/validate-rules.d.ts +4 -4
- package/dist/schema/validate-rules.js +15 -17
- package/dist/schemas/_ost_strict.json +1 -1
- package/dist/space-graph.d.ts +9 -9
- package/dist/space-graph.js +16 -16
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +3 -3
- package/dist/types.d.ts +14 -10
- package/dist/validate.d.ts +2 -2
- package/dist/validate.js +18 -19
- package/docs/concepts.md +26 -8
- package/docs/config.md +31 -6
- package/docs/rules.md +17 -13
- package/package.json +4 -4
- 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.
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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 &&
|
|
339
|
-
? [{ depth: 0, title:
|
|
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
|
|
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 (!
|
|
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
|
|
495
|
+
for (const pageTarget of page.targets)
|
|
496
|
+
targets.push(`${pageTarget}#${sectionTarget}`);
|
|
496
497
|
}
|
|
497
498
|
if (anchor) {
|
|
498
|
-
targets
|
|
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 (!
|
|
505
|
+
if (!page)
|
|
504
506
|
return [title];
|
|
505
507
|
const normalized = normalizeHeadingSectionTarget(title);
|
|
506
|
-
return normalized ?
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
const
|
|
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
|
-
|
|
107
|
+
id: file,
|
|
107
108
|
title,
|
|
108
109
|
schemaData: { title, ...data },
|
|
109
|
-
|
|
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
|
-
|
|
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(
|
|
10
|
-
if (seen.has(
|
|
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(
|
|
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,
|
|
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.
|
|
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 ? `${
|
|
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
|
-
|
|
41
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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,
|
|
35
|
-
// Deduplicate by (field,
|
|
36
|
-
if (!childNode.resolvedParents.some((r) => r.field === field && r.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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
|
|
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<
|
|
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<
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
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 {
|
|
90
|
-
const parentNode = nodeIndex.get(
|
|
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.
|
|
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) {
|
package/dist/schema/gating.d.ts
CHANGED
|
@@ -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
|
|
7
|
-
* `severity` is that of the
|
|
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
|
-
/**
|
|
15
|
+
/** Id (file) of the failing node. */
|
|
16
16
|
file: string;
|
|
17
|
-
/**
|
|
18
|
-
|
|
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
|
-
/**
|
|
27
|
+
/** Id (file) of the node whose failure triggered this gate. */
|
|
28
28
|
file: string;
|
|
29
|
-
/**
|
|
30
|
-
|
|
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<
|
|
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
|