structured-context 0.9.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 +348 -0
- package/dist/commands/diagram.d.ts +5 -0
- package/dist/commands/diagram.js +12 -0
- package/dist/commands/docs.d.ts +1 -0
- package/dist/commands/docs.js +67 -0
- package/dist/commands/dump.d.ts +2 -0
- package/dist/commands/dump.js +6 -0
- package/dist/commands/plugins.d.ts +1 -0
- package/dist/commands/plugins.js +23 -0
- package/dist/commands/render.d.ts +6 -0
- package/dist/commands/render.js +35 -0
- package/dist/commands/schemas.d.ts +6 -0
- package/dist/commands/schemas.js +268 -0
- package/dist/commands/show.d.ts +4 -0
- package/dist/commands/show.js +7 -0
- package/dist/commands/spaces.d.ts +1 -0
- package/dist/commands/spaces.js +36 -0
- package/dist/commands/template-sync.d.ts +3 -0
- package/dist/commands/template-sync.js +13 -0
- package/dist/commands/validate-file.d.ts +28 -0
- package/dist/commands/validate-file.js +133 -0
- package/dist/commands/validate.d.ts +16 -0
- package/dist/commands/validate.js +349 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.js +179 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/filter/augment-nodes.d.ts +23 -0
- package/dist/filter/augment-nodes.js +95 -0
- package/dist/filter/expand-include.d.ts +62 -0
- package/dist/filter/expand-include.js +181 -0
- package/dist/filter/filter-nodes.d.ts +21 -0
- package/dist/filter/filter-nodes.js +73 -0
- package/dist/filter/parse-expression.d.ts +20 -0
- package/dist/filter/parse-expression.js +60 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +161 -0
- package/dist/integrations/miro/cache.d.ts +21 -0
- package/dist/integrations/miro/cache.js +55 -0
- package/dist/integrations/miro/client.d.ts +99 -0
- package/dist/integrations/miro/client.js +118 -0
- package/dist/integrations/miro/layout.d.ts +28 -0
- package/dist/integrations/miro/layout.js +72 -0
- package/dist/integrations/miro/styles.d.ts +11 -0
- package/dist/integrations/miro/styles.js +65 -0
- package/dist/integrations/miro/sync.d.ts +8 -0
- package/dist/integrations/miro/sync.js +347 -0
- package/dist/plugin-api.d.ts +12 -0
- package/dist/plugin-api.js +7 -0
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.js +3 -0
- package/dist/plugins/loader.d.ts +21 -0
- package/dist/plugins/loader.js +104 -0
- package/dist/plugins/markdown/index.d.ts +48 -0
- package/dist/plugins/markdown/index.js +51 -0
- package/dist/plugins/markdown/parse-embedded.d.ts +90 -0
- package/dist/plugins/markdown/parse-embedded.js +663 -0
- package/dist/plugins/markdown/read-space.d.ts +7 -0
- package/dist/plugins/markdown/read-space.js +89 -0
- package/dist/plugins/markdown/render-bullets.d.ts +2 -0
- package/dist/plugins/markdown/render-bullets.js +42 -0
- package/dist/plugins/markdown/render-mermaid.d.ts +2 -0
- package/dist/plugins/markdown/render-mermaid.js +57 -0
- package/dist/plugins/markdown/template-sync.d.ts +16 -0
- package/dist/plugins/markdown/template-sync.js +294 -0
- package/dist/plugins/markdown/util.d.ts +19 -0
- package/dist/plugins/markdown/util.js +80 -0
- package/dist/plugins/util.d.ts +60 -0
- package/dist/plugins/util.js +7 -0
- package/dist/read/read-space.d.ts +2 -0
- package/dist/read/read-space.js +22 -0
- package/dist/read/resolve-graph-edges.d.ts +11 -0
- package/dist/read/resolve-graph-edges.js +201 -0
- package/dist/read/wikilink-utils.d.ts +16 -0
- package/dist/read/wikilink-utils.js +38 -0
- package/dist/render/registry.d.ts +13 -0
- package/dist/render/registry.js +22 -0
- package/dist/render/render.d.ts +4 -0
- package/dist/render/render.js +28 -0
- package/dist/schema/evaluate-rule.d.ts +30 -0
- package/dist/schema/evaluate-rule.js +82 -0
- package/dist/schema/metadata-contract.d.ts +538 -0
- package/dist/schema/metadata-contract.js +115 -0
- package/dist/schema/schema-refs.d.ts +22 -0
- package/dist/schema/schema-refs.js +168 -0
- package/dist/schema/schema.d.ts +27 -0
- package/dist/schema/schema.js +378 -0
- package/dist/schema/validate-graph.d.ts +24 -0
- package/dist/schema/validate-graph.js +141 -0
- package/dist/schema/validate-rules.d.ts +10 -0
- package/dist/schema/validate-rules.js +51 -0
- package/dist/schemas/_ost_strict.json +81 -0
- package/dist/schemas/_sctx_base.json +72 -0
- package/dist/schemas/general.json +261 -0
- package/dist/schemas/generated/_structured_context_schema_meta.json +191 -0
- package/dist/schemas/knowledge_wiki.json +206 -0
- package/dist/schemas/strict_ost.json +97 -0
- package/dist/space-graph.d.ts +28 -0
- package/dist/space-graph.js +82 -0
- package/dist/types.d.ts +145 -0
- package/dist/types.js +0 -0
- package/docs/concepts.md +391 -0
- package/docs/config.md +140 -0
- package/docs/rules.md +120 -0
- package/docs/schemas.md +340 -0
- package/package.json +69 -0
- package/schemas/_ost_strict.json +81 -0
- package/schemas/_sctx_base.json +72 -0
- package/schemas/general.json +261 -0
- package/schemas/generated/_structured_context_schema_meta.json +191 -0
- package/schemas/knowledge_wiki.json +206 -0
- package/schemas/strict_ost.json +97 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Flatten a SpaceNode's data fields for use in an augmented representation. */
|
|
2
|
+
function flattenData(node) {
|
|
3
|
+
return {
|
|
4
|
+
...node.schemaData,
|
|
5
|
+
label: node.label,
|
|
6
|
+
title: node.title,
|
|
7
|
+
resolvedType: node.resolvedType,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Build the augmented flat representation of a node, including pre-computed
|
|
12
|
+
* ancestors[] and descendants[] arrays with edge metadata.
|
|
13
|
+
*
|
|
14
|
+
* - ancestors: BFS from node via resolvedParents, nearest first, deduplicated by title.
|
|
15
|
+
* - descendants: BFS via childrenIndex, nearest first, deduplicated by title.
|
|
16
|
+
* - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _selfRef).
|
|
17
|
+
*/
|
|
18
|
+
export function augmentNode(node, nodeIndex, childrenIndex) {
|
|
19
|
+
const ancestors = buildAncestors(node, nodeIndex);
|
|
20
|
+
const descendants = buildDescendants(node, childrenIndex);
|
|
21
|
+
return {
|
|
22
|
+
...flattenData(node),
|
|
23
|
+
resolvedType: node.resolvedType,
|
|
24
|
+
resolvedParentTitles: node.resolvedParents.map((r) => r.title),
|
|
25
|
+
ancestors,
|
|
26
|
+
descendants,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function buildAncestors(node, nodeIndex) {
|
|
30
|
+
const visited = new Set();
|
|
31
|
+
const result = [];
|
|
32
|
+
// BFS queue holds: { parentRef that led to this node, the node itself }
|
|
33
|
+
const queue = [];
|
|
34
|
+
for (const ref of node.resolvedParents) {
|
|
35
|
+
const parentNode = nodeIndex.get(ref.title);
|
|
36
|
+
if (parentNode)
|
|
37
|
+
queue.push({ ref, node: parentNode });
|
|
38
|
+
}
|
|
39
|
+
while (queue.length > 0) {
|
|
40
|
+
const item = queue.shift();
|
|
41
|
+
const title = item.node.title;
|
|
42
|
+
if (visited.has(title))
|
|
43
|
+
continue;
|
|
44
|
+
visited.add(title);
|
|
45
|
+
result.push({
|
|
46
|
+
...flattenData(item.node),
|
|
47
|
+
_field: item.ref.field,
|
|
48
|
+
_source: item.ref.source,
|
|
49
|
+
_selfRef: item.ref.selfRef,
|
|
50
|
+
});
|
|
51
|
+
// Continue BFS upward
|
|
52
|
+
for (const ref of item.node.resolvedParents) {
|
|
53
|
+
const parentNode = nodeIndex.get(ref.title);
|
|
54
|
+
if (parentNode && !visited.has(ref.title)) {
|
|
55
|
+
queue.push({ ref, node: parentNode });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
function buildDescendants(node, childrenIndex) {
|
|
62
|
+
const nodeTitle = node.title;
|
|
63
|
+
const visited = new Set();
|
|
64
|
+
const result = [];
|
|
65
|
+
// BFS queue holds: child node + the resolvedParents entry on that child that points to its parent
|
|
66
|
+
const queue = [];
|
|
67
|
+
const directChildren = childrenIndex.get(nodeTitle) ?? [];
|
|
68
|
+
for (const child of directChildren) {
|
|
69
|
+
const ref = child.resolvedParents.find((r) => r.title === nodeTitle);
|
|
70
|
+
if (ref)
|
|
71
|
+
queue.push({ childNode: child, ref });
|
|
72
|
+
}
|
|
73
|
+
while (queue.length > 0) {
|
|
74
|
+
const item = queue.shift();
|
|
75
|
+
const title = item.childNode.title;
|
|
76
|
+
if (visited.has(title))
|
|
77
|
+
continue;
|
|
78
|
+
visited.add(title);
|
|
79
|
+
result.push({
|
|
80
|
+
...flattenData(item.childNode),
|
|
81
|
+
_field: item.ref.field,
|
|
82
|
+
_source: item.ref.source,
|
|
83
|
+
_selfRef: item.ref.selfRef,
|
|
84
|
+
});
|
|
85
|
+
const grandchildren = childrenIndex.get(title) ?? [];
|
|
86
|
+
for (const grandchild of grandchildren) {
|
|
87
|
+
if (!visited.has(grandchild.title)) {
|
|
88
|
+
const ref = grandchild.resolvedParents.find((r) => r.title === title);
|
|
89
|
+
if (ref)
|
|
90
|
+
queue.push({ childNode: grandchild, ref });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { SpaceNode } from '../types';
|
|
2
|
+
import type { AugmentedFlatNode } from './augment-nodes';
|
|
3
|
+
export type AncestorsDirective = {
|
|
4
|
+
kind: 'ancestors';
|
|
5
|
+
/** Filter by resolved type of the ancestor node. Absent means include all. */
|
|
6
|
+
typeFilter?: string;
|
|
7
|
+
};
|
|
8
|
+
export type DescendantsDirective = {
|
|
9
|
+
kind: 'descendants';
|
|
10
|
+
/** Filter by resolved type of the descendant node. Absent means include all. */
|
|
11
|
+
typeFilter?: string;
|
|
12
|
+
};
|
|
13
|
+
export type SiblingsDirective = {
|
|
14
|
+
kind: 'siblings';
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Relationship directive covers all non-hierarchy edges.
|
|
18
|
+
* Progressive specification mirrors the `parent_type:field:child_type` naming convention.
|
|
19
|
+
* Any combination of filters may be present; absent fields are treated as wildcards.
|
|
20
|
+
*/
|
|
21
|
+
export type RelationshipsDirective = {
|
|
22
|
+
kind: 'relationships';
|
|
23
|
+
/** Filter by the child side's resolved type. */
|
|
24
|
+
childType?: string;
|
|
25
|
+
/** Filter by the parent side's resolved type. */
|
|
26
|
+
parentType?: string;
|
|
27
|
+
/** Filter by the edge field name. */
|
|
28
|
+
field?: string;
|
|
29
|
+
};
|
|
30
|
+
export type IncludeDirective = AncestorsDirective | DescendantsDirective | SiblingsDirective | RelationshipsDirective;
|
|
31
|
+
/**
|
|
32
|
+
* Parse a SELECT include spec string into a list of directives.
|
|
33
|
+
*
|
|
34
|
+
* Grammar:
|
|
35
|
+
* spec = directive (',' directive)*
|
|
36
|
+
* directive = 'ancestors' ('(' type ')')?
|
|
37
|
+
* | 'descendants' ('(' type ')')?
|
|
38
|
+
* | 'siblings'
|
|
39
|
+
* | 'relationships' ('(' relSpec ')')?
|
|
40
|
+
* type = identifier
|
|
41
|
+
* relSpec = childType
|
|
42
|
+
* | parentType ':' childType
|
|
43
|
+
* | parentType ':' field ':' childType
|
|
44
|
+
*
|
|
45
|
+
* Keywords are case-insensitive. Identifiers match \w+.
|
|
46
|
+
* Range syntax (type..type) is reserved for a future release.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseIncludeSpec(spec: string): IncludeDirective[];
|
|
49
|
+
/**
|
|
50
|
+
* Expand the matched node set by adding nodes specified by the include directives.
|
|
51
|
+
*
|
|
52
|
+
* Processes each matched node and each directive, collecting additional nodes to
|
|
53
|
+
* include. The result is `matched ∪ expanded`, preserving original order with new
|
|
54
|
+
* nodes appended in the order they are discovered.
|
|
55
|
+
*
|
|
56
|
+
* @param matchedNodes - Nodes already matched by the WHERE clause (or all nodes for SELECT-only)
|
|
57
|
+
* @param directives - Parsed include directives from the SELECT clause
|
|
58
|
+
* @param nodeIndex - Title → SpaceNode lookup
|
|
59
|
+
* @param childrenIndex - Title → direct children (all edges)
|
|
60
|
+
* @param augmented - Title → AugmentedFlatNode with pre-computed ancestors/descendants
|
|
61
|
+
*/
|
|
62
|
+
export declare function expandInclude(matchedNodes: SpaceNode[], directives: IncludeDirective[], nodeIndex: ReadonlyMap<string, SpaceNode>, childrenIndex: ReadonlyMap<string, readonly SpaceNode[]>, augmented: Map<string, AugmentedFlatNode>): SpaceNode[];
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Parser
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
/**
|
|
5
|
+
* Parse a SELECT include spec string into a list of directives.
|
|
6
|
+
*
|
|
7
|
+
* Grammar:
|
|
8
|
+
* spec = directive (',' directive)*
|
|
9
|
+
* directive = 'ancestors' ('(' type ')')?
|
|
10
|
+
* | 'descendants' ('(' type ')')?
|
|
11
|
+
* | 'siblings'
|
|
12
|
+
* | 'relationships' ('(' relSpec ')')?
|
|
13
|
+
* type = identifier
|
|
14
|
+
* relSpec = childType
|
|
15
|
+
* | parentType ':' childType
|
|
16
|
+
* | parentType ':' field ':' childType
|
|
17
|
+
*
|
|
18
|
+
* Keywords are case-insensitive. Identifiers match \w+.
|
|
19
|
+
* Range syntax (type..type) is reserved for a future release.
|
|
20
|
+
*/
|
|
21
|
+
export function parseIncludeSpec(spec) {
|
|
22
|
+
const items = spec
|
|
23
|
+
.split(',')
|
|
24
|
+
.map((s) => s.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
if (items.length === 0)
|
|
27
|
+
throw new Error('SELECT spec must not be empty');
|
|
28
|
+
return items.map(parseDirective);
|
|
29
|
+
}
|
|
30
|
+
function parseDirective(item) {
|
|
31
|
+
const match = item.match(/^(\w+)(?:\(([^)]*)\))?$/i);
|
|
32
|
+
if (!match)
|
|
33
|
+
throw new Error(`Invalid include directive: "${item}"`);
|
|
34
|
+
const name = match[1].toLowerCase();
|
|
35
|
+
const arg = match[2]?.trim();
|
|
36
|
+
if (name === 'siblings') {
|
|
37
|
+
if (arg !== undefined && arg !== '')
|
|
38
|
+
throw new Error('siblings() does not accept arguments');
|
|
39
|
+
return { kind: 'siblings' };
|
|
40
|
+
}
|
|
41
|
+
if (name === 'ancestors' || name === 'descendants') {
|
|
42
|
+
if (!arg)
|
|
43
|
+
return { kind: name };
|
|
44
|
+
if (arg.includes('..')) {
|
|
45
|
+
throw new Error(`Range syntax "${arg}" in SELECT is not yet supported. Use a plain type name for now.`);
|
|
46
|
+
}
|
|
47
|
+
if (!/^\w+$/.test(arg))
|
|
48
|
+
throw new Error(`Invalid type name in ${name}(): "${arg}"`);
|
|
49
|
+
return { kind: name, typeFilter: arg };
|
|
50
|
+
}
|
|
51
|
+
if (name === 'relationships') {
|
|
52
|
+
if (!arg)
|
|
53
|
+
return { kind: 'relationships' };
|
|
54
|
+
const parts = arg.split(':').map((s) => s.trim());
|
|
55
|
+
if (parts.some((p) => !/^\w+$/.test(p))) {
|
|
56
|
+
throw new Error(`Invalid relationship spec: "${arg}"`);
|
|
57
|
+
}
|
|
58
|
+
if (parts.length === 1)
|
|
59
|
+
return { kind: 'relationships', childType: parts[0] };
|
|
60
|
+
if (parts.length === 2)
|
|
61
|
+
return { kind: 'relationships', parentType: parts[0], childType: parts[1] };
|
|
62
|
+
if (parts.length === 3) {
|
|
63
|
+
return { kind: 'relationships', parentType: parts[0], field: parts[1], childType: parts[2] };
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Relationship spec "${arg}" has too many parts (max 3: parent:field:child)`);
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`Unknown include directive "${item}". Expected: ancestors, descendants, siblings, relationships`);
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Expander
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
/**
|
|
73
|
+
* Expand the matched node set by adding nodes specified by the include directives.
|
|
74
|
+
*
|
|
75
|
+
* Processes each matched node and each directive, collecting additional nodes to
|
|
76
|
+
* include. The result is `matched ∪ expanded`, preserving original order with new
|
|
77
|
+
* nodes appended in the order they are discovered.
|
|
78
|
+
*
|
|
79
|
+
* @param matchedNodes - Nodes already matched by the WHERE clause (or all nodes for SELECT-only)
|
|
80
|
+
* @param directives - Parsed include directives from the SELECT clause
|
|
81
|
+
* @param nodeIndex - Title → SpaceNode lookup
|
|
82
|
+
* @param childrenIndex - Title → direct children (all edges)
|
|
83
|
+
* @param augmented - Title → AugmentedFlatNode with pre-computed ancestors/descendants
|
|
84
|
+
*/
|
|
85
|
+
export function expandInclude(matchedNodes, directives, nodeIndex, childrenIndex, augmented) {
|
|
86
|
+
if (directives.length === 0)
|
|
87
|
+
return matchedNodes;
|
|
88
|
+
const seen = new Set(matchedNodes.map((n) => n.title));
|
|
89
|
+
const result = [...matchedNodes];
|
|
90
|
+
function addByTitle(title) {
|
|
91
|
+
if (seen.has(title))
|
|
92
|
+
return;
|
|
93
|
+
const node = nodeIndex.get(title);
|
|
94
|
+
if (node) {
|
|
95
|
+
seen.add(title);
|
|
96
|
+
result.push(node);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const node of matchedNodes) {
|
|
100
|
+
const title = node.title;
|
|
101
|
+
const aug = augmented.get(title);
|
|
102
|
+
if (!aug)
|
|
103
|
+
continue;
|
|
104
|
+
for (const directive of directives) {
|
|
105
|
+
applyDirective(node, aug, directive, childrenIndex, addByTitle);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
function applyDirective(node, aug, directive, childrenIndex, addByTitle) {
|
|
111
|
+
switch (directive.kind) {
|
|
112
|
+
case 'ancestors': {
|
|
113
|
+
for (const a of aug.ancestors) {
|
|
114
|
+
if (!directive.typeFilter || a.resolvedType === directive.typeFilter) {
|
|
115
|
+
addByTitle(a.title);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case 'descendants': {
|
|
121
|
+
for (const d of aug.descendants) {
|
|
122
|
+
if (!directive.typeFilter || d.resolvedType === directive.typeFilter) {
|
|
123
|
+
addByTitle(d.title);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case 'siblings': {
|
|
129
|
+
// Nodes that share at least one parent with the matched node (any edge type)
|
|
130
|
+
for (const parentRef of node.resolvedParents) {
|
|
131
|
+
const siblings = childrenIndex.get(parentRef.title) ?? [];
|
|
132
|
+
for (const sibling of siblings) {
|
|
133
|
+
const siblingTitle = sibling.title;
|
|
134
|
+
if (siblingTitle !== node.title) {
|
|
135
|
+
addByTitle(siblingTitle);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'relationships': {
|
|
142
|
+
// Relationship-sourced ancestors (matched node is the child side)
|
|
143
|
+
for (const a of aug.ancestors) {
|
|
144
|
+
if (a._source === 'relationship' && matchesRelSpec(node, a, directive, true)) {
|
|
145
|
+
addByTitle(a.title);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Relationship-sourced descendants (matched node is the parent side)
|
|
149
|
+
for (const d of aug.descendants) {
|
|
150
|
+
if (d._source === 'relationship' && matchesRelSpec(node, d, directive, false)) {
|
|
151
|
+
addByTitle(d.title);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Check whether a relationship edge entry matches the directive's filter spec.
|
|
160
|
+
*
|
|
161
|
+
* @param matchedNode - The node from the matched set
|
|
162
|
+
* @param entry - An ancestor or descendant entry with edge metadata
|
|
163
|
+
* @param directive - The relationships directive with optional type/field filters
|
|
164
|
+
* @param entryIsParent - true if entry is the parent side (ancestor), false if child side (descendant)
|
|
165
|
+
*/
|
|
166
|
+
function matchesRelSpec(matchedNode, entry, directive, entryIsParent) {
|
|
167
|
+
if (!directive.childType && !directive.parentType && !directive.field)
|
|
168
|
+
return true;
|
|
169
|
+
const entryType = entry.resolvedType;
|
|
170
|
+
const matchedType = matchedNode.resolvedType;
|
|
171
|
+
// When entry is the parent, matched node is the child, and vice versa
|
|
172
|
+
const parentType = entryIsParent ? entryType : matchedType;
|
|
173
|
+
const childType = entryIsParent ? matchedType : entryType;
|
|
174
|
+
if (directive.parentType && parentType !== directive.parentType)
|
|
175
|
+
return false;
|
|
176
|
+
if (directive.childType && childType !== directive.childType)
|
|
177
|
+
return false;
|
|
178
|
+
if (directive.field && entry._field !== directive.field)
|
|
179
|
+
return false;
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type SpaceGraph } from '../space-graph';
|
|
2
|
+
/**
|
|
3
|
+
* Filter a SpaceGraph using a filter expression, returning a new SpaceGraph.
|
|
4
|
+
*
|
|
5
|
+
* The expression follows the SELECT...WHERE DSL:
|
|
6
|
+
* WHERE {jsonata} — return nodes where the JSONata predicate is truthy
|
|
7
|
+
* SELECT {spec} WHERE {jsonata} — filter + expand result via include spec
|
|
8
|
+
* SELECT {spec} — expand from all nodes via include spec
|
|
9
|
+
* {jsonata} — bare JSONata treated as WHERE predicate
|
|
10
|
+
*
|
|
11
|
+
* Each node's WHERE predicate is evaluated against an augmented context that includes
|
|
12
|
+
* pre-computed ancestors[] and descendants[] arrays with edge metadata.
|
|
13
|
+
*
|
|
14
|
+
* The SELECT spec may contain: ancestors[(type)], descendants[(type)], siblings,
|
|
15
|
+
* relationships[(childType | parentType:childType | parentType:field:childType)]
|
|
16
|
+
*
|
|
17
|
+
* @param expression - Filter DSL expression or view expression string
|
|
18
|
+
* @param graph - The full space graph
|
|
19
|
+
* @returns A new SpaceGraph containing only the filtered+expanded nodes
|
|
20
|
+
*/
|
|
21
|
+
export declare function filterNodes(expression: string, graph: SpaceGraph): Promise<SpaceGraph>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import jsonata from 'jsonata';
|
|
2
|
+
import { buildSpaceGraph } from '../space-graph';
|
|
3
|
+
import { augmentNode } from './augment-nodes';
|
|
4
|
+
import { expandInclude, parseIncludeSpec } from './expand-include';
|
|
5
|
+
import { parseFilterExpression } from './parse-expression';
|
|
6
|
+
const expressionCache = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* Filter a SpaceGraph using a filter expression, returning a new SpaceGraph.
|
|
9
|
+
*
|
|
10
|
+
* The expression follows the SELECT...WHERE DSL:
|
|
11
|
+
* WHERE {jsonata} — return nodes where the JSONata predicate is truthy
|
|
12
|
+
* SELECT {spec} WHERE {jsonata} — filter + expand result via include spec
|
|
13
|
+
* SELECT {spec} — expand from all nodes via include spec
|
|
14
|
+
* {jsonata} — bare JSONata treated as WHERE predicate
|
|
15
|
+
*
|
|
16
|
+
* Each node's WHERE predicate is evaluated against an augmented context that includes
|
|
17
|
+
* pre-computed ancestors[] and descendants[] arrays with edge metadata.
|
|
18
|
+
*
|
|
19
|
+
* The SELECT spec may contain: ancestors[(type)], descendants[(type)], siblings,
|
|
20
|
+
* relationships[(childType | parentType:childType | parentType:field:childType)]
|
|
21
|
+
*
|
|
22
|
+
* @param expression - Filter DSL expression or view expression string
|
|
23
|
+
* @param graph - The full space graph
|
|
24
|
+
* @returns A new SpaceGraph containing only the filtered+expanded nodes
|
|
25
|
+
*/
|
|
26
|
+
export async function filterNodes(expression, graph) {
|
|
27
|
+
const { where, include } = parseFilterExpression(expression);
|
|
28
|
+
const nodeIndex = graph.nodes;
|
|
29
|
+
const childrenIndex = graph.children;
|
|
30
|
+
// Pre-augment all nodes once (ancestors/descendants needed for WHERE predicates and SELECT expansion)
|
|
31
|
+
const augmented = new Map();
|
|
32
|
+
for (const node of nodeIndex.values()) {
|
|
33
|
+
augmented.set(node.title, augmentNode(node, nodeIndex, childrenIndex));
|
|
34
|
+
}
|
|
35
|
+
// Step 1: apply WHERE clause to get the matched set
|
|
36
|
+
let matched;
|
|
37
|
+
if (where === undefined) {
|
|
38
|
+
// SELECT-only: start from all nodes
|
|
39
|
+
matched = [...nodeIndex.values()];
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const allAugmented = Array.from(augmented.values());
|
|
43
|
+
// Compile and cache the JSONata expression
|
|
44
|
+
let expr = expressionCache.get(where);
|
|
45
|
+
if (!expr) {
|
|
46
|
+
expr = jsonata(where);
|
|
47
|
+
expressionCache.set(where, expr);
|
|
48
|
+
}
|
|
49
|
+
matched = [];
|
|
50
|
+
for (const node of nodeIndex.values()) {
|
|
51
|
+
const current = augmented.get(node.title);
|
|
52
|
+
if (!current)
|
|
53
|
+
continue;
|
|
54
|
+
// Spread current node fields at root level so bare field names work in expressions
|
|
55
|
+
// (e.g. `resolvedType='solution'` rather than `current.resolvedType='solution'`).
|
|
56
|
+
// Also expose `ancestors` and `descendants` directly, and `nodes` for cross-node access.
|
|
57
|
+
const input = { ...current, nodes: allAugmented };
|
|
58
|
+
const result = await expr.evaluate(input);
|
|
59
|
+
if (result)
|
|
60
|
+
matched.push(node);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Step 2: apply SELECT clause to expand the result set
|
|
64
|
+
let matchedNodes;
|
|
65
|
+
if (include !== undefined) {
|
|
66
|
+
const directives = parseIncludeSpec(include);
|
|
67
|
+
matchedNodes = expandInclude(matched, directives, nodeIndex, childrenIndex, augmented);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
matchedNodes = matched;
|
|
71
|
+
}
|
|
72
|
+
return buildSpaceGraph(matchedNodes, graph.levels);
|
|
73
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the filter expression DSL.
|
|
3
|
+
*
|
|
4
|
+
* Grammar (keywords are case-insensitive):
|
|
5
|
+
* WHERE {jsonata} — filter predicate only
|
|
6
|
+
* SELECT {spec} WHERE {jsonata} — include spec + filter predicate
|
|
7
|
+
* SELECT {spec} — include spec only (Phase 2: expansion from all nodes)
|
|
8
|
+
* {jsonata} — bare JSONata treated as WHERE predicate (convenience)
|
|
9
|
+
*/
|
|
10
|
+
export type ParsedFilterExpression = {
|
|
11
|
+
/** JSONata predicate evaluated per node. Absent means match all. */
|
|
12
|
+
where?: string;
|
|
13
|
+
/** Include spec for result expansion (Phase 1: stored but not evaluated). */
|
|
14
|
+
include?: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Parse a filter expression string into its WHERE and SELECT parts.
|
|
18
|
+
* Throws a descriptive error on malformed input.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseFilterExpression(expr: string): ParsedFilterExpression;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the filter expression DSL.
|
|
3
|
+
*
|
|
4
|
+
* Grammar (keywords are case-insensitive):
|
|
5
|
+
* WHERE {jsonata} — filter predicate only
|
|
6
|
+
* SELECT {spec} WHERE {jsonata} — include spec + filter predicate
|
|
7
|
+
* SELECT {spec} — include spec only (Phase 2: expansion from all nodes)
|
|
8
|
+
* {jsonata} — bare JSONata treated as WHERE predicate (convenience)
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Parse a filter expression string into its WHERE and SELECT parts.
|
|
12
|
+
* Throws a descriptive error on malformed input.
|
|
13
|
+
*/
|
|
14
|
+
export function parseFilterExpression(expr) {
|
|
15
|
+
const trimmed = expr.trim();
|
|
16
|
+
if (!trimmed) {
|
|
17
|
+
throw new Error('Filter expression must not be empty');
|
|
18
|
+
}
|
|
19
|
+
// Case-insensitive keyword detection using regex
|
|
20
|
+
// SELECT ... WHERE ... (both present)
|
|
21
|
+
const selectWhereMatch = trimmed.match(/^SELECT\s+([\s\S]+?)\s+WHERE\s+([\s\S]+)$/i);
|
|
22
|
+
if (selectWhereMatch) {
|
|
23
|
+
const include = selectWhereMatch[1].trim();
|
|
24
|
+
const where = selectWhereMatch[2].trim();
|
|
25
|
+
if (!include)
|
|
26
|
+
throw new Error('SELECT clause must not be empty');
|
|
27
|
+
if (!where)
|
|
28
|
+
throw new Error('WHERE clause must not be empty');
|
|
29
|
+
return { include, where };
|
|
30
|
+
}
|
|
31
|
+
// SELECT ... (no WHERE)
|
|
32
|
+
const selectOnlyMatch = trimmed.match(/^SELECT\s+([\s\S]+)$/i);
|
|
33
|
+
if (selectOnlyMatch) {
|
|
34
|
+
const include = selectOnlyMatch[1].trim();
|
|
35
|
+
if (!include)
|
|
36
|
+
throw new Error('SELECT clause must not be empty');
|
|
37
|
+
// Detect SELECT {spec} WHERE (trailing WHERE with no content)
|
|
38
|
+
if (/\sWHERE\s*$/i.test(include)) {
|
|
39
|
+
throw new Error('WHERE clause must not be empty');
|
|
40
|
+
}
|
|
41
|
+
return { include };
|
|
42
|
+
}
|
|
43
|
+
// WHERE ... (no SELECT)
|
|
44
|
+
const whereOnlyMatch = trimmed.match(/^WHERE\s+([\s\S]+)$/i);
|
|
45
|
+
if (whereOnlyMatch) {
|
|
46
|
+
const where = whereOnlyMatch[1].trim();
|
|
47
|
+
if (!where)
|
|
48
|
+
throw new Error('WHERE clause must not be empty');
|
|
49
|
+
return { where };
|
|
50
|
+
}
|
|
51
|
+
// Detect a keyword used without any content (e.g. just "WHERE" or "SELECT")
|
|
52
|
+
if (/^WHERE\s*$/i.test(trimmed)) {
|
|
53
|
+
throw new Error('WHERE clause must not be empty');
|
|
54
|
+
}
|
|
55
|
+
if (/^SELECT\s*$/i.test(trimmed)) {
|
|
56
|
+
throw new Error('SELECT clause must not be empty');
|
|
57
|
+
}
|
|
58
|
+
// Bare JSONata — treat as WHERE predicate
|
|
59
|
+
return { where: trimmed };
|
|
60
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { diagram } from './commands/diagram';
|
|
5
|
+
import { docs } from './commands/docs';
|
|
6
|
+
import { dump } from './commands/dump';
|
|
7
|
+
import { listPlugins } from './commands/plugins';
|
|
8
|
+
import { render, renderList } from './commands/render';
|
|
9
|
+
import { listSchemas, showSchema } from './commands/schemas';
|
|
10
|
+
import { show } from './commands/show';
|
|
11
|
+
import { listSpaces } from './commands/spaces';
|
|
12
|
+
import { templateSync } from './commands/template-sync';
|
|
13
|
+
import { validate, watchValidate } from './commands/validate';
|
|
14
|
+
import { validateFile } from './commands/validate-file';
|
|
15
|
+
import { getSpaceConfigDir, loadConfig, resolveSchema, setConfigPath } from './config';
|
|
16
|
+
import { CLI_NAME } from './constants';
|
|
17
|
+
import { miroSync } from './integrations/miro/sync';
|
|
18
|
+
import { loadSchema } from './schema/schema';
|
|
19
|
+
export function buildSpaceContext(spaceName) {
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
const space = config.spaces.find((s) => s.name === spaceName);
|
|
22
|
+
if (!space) {
|
|
23
|
+
console.error(`Error: Unknown space "${spaceName}"`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const resolvedSchemaPath = resolveSchema(config, space);
|
|
27
|
+
const { schema, schemaRefRegistry, schemaValidator } = loadSchema(resolvedSchemaPath);
|
|
28
|
+
const configDir = getSpaceConfigDir(space.name);
|
|
29
|
+
return {
|
|
30
|
+
space,
|
|
31
|
+
config,
|
|
32
|
+
resolvedSchemaPath,
|
|
33
|
+
schema,
|
|
34
|
+
schemaRefRegistry,
|
|
35
|
+
schemaValidator,
|
|
36
|
+
configDir,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const require = createRequire(import.meta.url);
|
|
40
|
+
const packageJson = require('../package.json');
|
|
41
|
+
const program = new Command();
|
|
42
|
+
program
|
|
43
|
+
.name(CLI_NAME)
|
|
44
|
+
.description('Structured context validation and diagram generation tool')
|
|
45
|
+
.version(packageJson.version)
|
|
46
|
+
.option('--config <path>', 'Path to config file (overrides default config.json locations)');
|
|
47
|
+
program.hook('preAction', () => {
|
|
48
|
+
setConfigPath(program.opts().config);
|
|
49
|
+
});
|
|
50
|
+
program
|
|
51
|
+
.command('validate-file')
|
|
52
|
+
.description('Validate a single file within its space')
|
|
53
|
+
.argument('<path>', 'Path to the file to validate')
|
|
54
|
+
.option('--json', 'Output results as JSON (machine-readable, for hooks)')
|
|
55
|
+
.action(async (filePath, options) => {
|
|
56
|
+
const exitCode = await validateFile(filePath, { json: options.json });
|
|
57
|
+
process.exit(exitCode);
|
|
58
|
+
});
|
|
59
|
+
program
|
|
60
|
+
.command('validate')
|
|
61
|
+
.description('Validate space against JSON schema')
|
|
62
|
+
.argument('<space-name>', 'Space name')
|
|
63
|
+
.option('-w, --watch', 'Watch for changes and re-run validation')
|
|
64
|
+
.option('--json', 'Output results as JSON')
|
|
65
|
+
.action(async (spaceName, options) => {
|
|
66
|
+
const context = buildSpaceContext(spaceName);
|
|
67
|
+
if (options.watch) {
|
|
68
|
+
await watchValidate(context);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const exitCode = await validate(context, { json: options.json });
|
|
72
|
+
process.exit(exitCode);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
program
|
|
76
|
+
.command('diagram')
|
|
77
|
+
.description('Generate mermaid diagram from space')
|
|
78
|
+
.argument('<space-name>', 'Space name')
|
|
79
|
+
.option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
|
|
80
|
+
.option('-o, --output <path>', 'Output file path (default: stdout)')
|
|
81
|
+
.action((spaceName, options) => diagram(buildSpaceContext(spaceName), options));
|
|
82
|
+
program
|
|
83
|
+
.command('show')
|
|
84
|
+
.description('Print space graph as a bullet list')
|
|
85
|
+
.argument('<space-name>', 'Space name')
|
|
86
|
+
.option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
|
|
87
|
+
.action((spaceName, options) => show(buildSpaceContext(spaceName), options));
|
|
88
|
+
program
|
|
89
|
+
.command('dump')
|
|
90
|
+
.description('Dump parsed space nodes as JSON')
|
|
91
|
+
.argument('<space-name>', 'Space name')
|
|
92
|
+
.action((spaceName) => dump(buildSpaceContext(spaceName)));
|
|
93
|
+
program
|
|
94
|
+
.command('miro-sync')
|
|
95
|
+
.description('Sync space to a Miro board')
|
|
96
|
+
.argument('<space-name>', 'Space name (must have miroBoardId in config)')
|
|
97
|
+
.option('--new-frame <title>', 'Create a new frame on the board and sync into it')
|
|
98
|
+
.option('--dry-run', 'Show what would change without touching Miro')
|
|
99
|
+
.option('-v, --verbose', 'Detailed output')
|
|
100
|
+
.action((spaceName, options) => miroSync(buildSpaceContext(spaceName), options));
|
|
101
|
+
program
|
|
102
|
+
.command('template-sync')
|
|
103
|
+
.description('Sync schema examples to templates')
|
|
104
|
+
.argument('<space-name>', 'Space name')
|
|
105
|
+
.option('--create-missing', 'Create missing template files for schema types')
|
|
106
|
+
.option('--dry-run', 'Preview changes without writing files')
|
|
107
|
+
.action((spaceName, options) => {
|
|
108
|
+
const context = buildSpaceContext(spaceName);
|
|
109
|
+
templateSync(context, options);
|
|
110
|
+
});
|
|
111
|
+
program
|
|
112
|
+
.command('plugins')
|
|
113
|
+
.description('List available plugins')
|
|
114
|
+
.action(async () => listPlugins());
|
|
115
|
+
const spacesCmd = new Command('spaces').description('List configured spaces');
|
|
116
|
+
spacesCmd
|
|
117
|
+
.command('list', { isDefault: true })
|
|
118
|
+
.description('List all configured spaces and their paths')
|
|
119
|
+
.action(listSpaces);
|
|
120
|
+
program.addCommand(spacesCmd);
|
|
121
|
+
const schemasCmd = new Command('schemas').alias('schema').description('List and inspect schemas');
|
|
122
|
+
schemasCmd
|
|
123
|
+
.command('list', { isDefault: true })
|
|
124
|
+
.description('List available schemas')
|
|
125
|
+
.action(() => listSchemas());
|
|
126
|
+
schemasCmd
|
|
127
|
+
.command('show')
|
|
128
|
+
.description('Show schema structure (or raw JSON with --raw, or Mermaid ERD with --mermaid-erd)')
|
|
129
|
+
.argument('[file]', 'Schema filename or path (omit to use --space)')
|
|
130
|
+
.option('--space <name>', 'Resolve schema from space config')
|
|
131
|
+
.option('--raw', 'Output raw JSON file content')
|
|
132
|
+
.option('--mermaid-erd', 'Output Mermaid Entity Relationship Diagram')
|
|
133
|
+
.action((file, options) => showSchema(file, {
|
|
134
|
+
space: options.space,
|
|
135
|
+
raw: options.raw ?? false,
|
|
136
|
+
mermaidErd: options.mermaidErd ?? false,
|
|
137
|
+
}));
|
|
138
|
+
program.addCommand(schemasCmd);
|
|
139
|
+
program
|
|
140
|
+
.command('docs [topic]')
|
|
141
|
+
.description('Show documentation (no arg: README; topics: concepts, config, schema, rules)')
|
|
142
|
+
.action((topic) => docs(topic));
|
|
143
|
+
const renderCmd = new Command('render').description('Render a space in a given format');
|
|
144
|
+
renderCmd
|
|
145
|
+
.command('list', { isDefault: false })
|
|
146
|
+
.description('List available render formats')
|
|
147
|
+
.argument('[space-name]', 'Space name (optional, to show space-specific formats)')
|
|
148
|
+
.action(async (spaceName) => {
|
|
149
|
+
const context = spaceName ? buildSpaceContext(spaceName) : undefined;
|
|
150
|
+
await renderList(context);
|
|
151
|
+
});
|
|
152
|
+
renderCmd
|
|
153
|
+
.argument('<space-name>', 'Space name')
|
|
154
|
+
.argument('<format>', 'Render format (e.g. markdown.bullets)')
|
|
155
|
+
.option('--filter <filter>', 'Filter view name (from config) or inline filter expression')
|
|
156
|
+
.option('-o, --output <path>', 'Output file path (default: stdout)')
|
|
157
|
+
.action(async (spaceName, format, options) => {
|
|
158
|
+
await render(buildSpaceContext(spaceName), format, options);
|
|
159
|
+
});
|
|
160
|
+
program.addCommand(renderCmd);
|
|
161
|
+
program.parse();
|