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.
Files changed (112) hide show
  1. package/README.md +348 -0
  2. package/dist/commands/diagram.d.ts +5 -0
  3. package/dist/commands/diagram.js +12 -0
  4. package/dist/commands/docs.d.ts +1 -0
  5. package/dist/commands/docs.js +67 -0
  6. package/dist/commands/dump.d.ts +2 -0
  7. package/dist/commands/dump.js +6 -0
  8. package/dist/commands/plugins.d.ts +1 -0
  9. package/dist/commands/plugins.js +23 -0
  10. package/dist/commands/render.d.ts +6 -0
  11. package/dist/commands/render.js +35 -0
  12. package/dist/commands/schemas.d.ts +6 -0
  13. package/dist/commands/schemas.js +268 -0
  14. package/dist/commands/show.d.ts +4 -0
  15. package/dist/commands/show.js +7 -0
  16. package/dist/commands/spaces.d.ts +1 -0
  17. package/dist/commands/spaces.js +36 -0
  18. package/dist/commands/template-sync.d.ts +3 -0
  19. package/dist/commands/template-sync.js +13 -0
  20. package/dist/commands/validate-file.d.ts +28 -0
  21. package/dist/commands/validate-file.js +133 -0
  22. package/dist/commands/validate.d.ts +16 -0
  23. package/dist/commands/validate.js +349 -0
  24. package/dist/config.d.ts +38 -0
  25. package/dist/config.js +179 -0
  26. package/dist/constants.d.ts +6 -0
  27. package/dist/constants.js +6 -0
  28. package/dist/filter/augment-nodes.d.ts +23 -0
  29. package/dist/filter/augment-nodes.js +95 -0
  30. package/dist/filter/expand-include.d.ts +62 -0
  31. package/dist/filter/expand-include.js +181 -0
  32. package/dist/filter/filter-nodes.d.ts +21 -0
  33. package/dist/filter/filter-nodes.js +73 -0
  34. package/dist/filter/parse-expression.d.ts +20 -0
  35. package/dist/filter/parse-expression.js +60 -0
  36. package/dist/index.d.ts +3 -0
  37. package/dist/index.js +161 -0
  38. package/dist/integrations/miro/cache.d.ts +21 -0
  39. package/dist/integrations/miro/cache.js +55 -0
  40. package/dist/integrations/miro/client.d.ts +99 -0
  41. package/dist/integrations/miro/client.js +118 -0
  42. package/dist/integrations/miro/layout.d.ts +28 -0
  43. package/dist/integrations/miro/layout.js +72 -0
  44. package/dist/integrations/miro/styles.d.ts +11 -0
  45. package/dist/integrations/miro/styles.js +65 -0
  46. package/dist/integrations/miro/sync.d.ts +8 -0
  47. package/dist/integrations/miro/sync.js +347 -0
  48. package/dist/plugin-api.d.ts +12 -0
  49. package/dist/plugin-api.js +7 -0
  50. package/dist/plugins/index.d.ts +3 -0
  51. package/dist/plugins/index.js +3 -0
  52. package/dist/plugins/loader.d.ts +21 -0
  53. package/dist/plugins/loader.js +104 -0
  54. package/dist/plugins/markdown/index.d.ts +48 -0
  55. package/dist/plugins/markdown/index.js +51 -0
  56. package/dist/plugins/markdown/parse-embedded.d.ts +90 -0
  57. package/dist/plugins/markdown/parse-embedded.js +663 -0
  58. package/dist/plugins/markdown/read-space.d.ts +7 -0
  59. package/dist/plugins/markdown/read-space.js +89 -0
  60. package/dist/plugins/markdown/render-bullets.d.ts +2 -0
  61. package/dist/plugins/markdown/render-bullets.js +42 -0
  62. package/dist/plugins/markdown/render-mermaid.d.ts +2 -0
  63. package/dist/plugins/markdown/render-mermaid.js +57 -0
  64. package/dist/plugins/markdown/template-sync.d.ts +16 -0
  65. package/dist/plugins/markdown/template-sync.js +294 -0
  66. package/dist/plugins/markdown/util.d.ts +19 -0
  67. package/dist/plugins/markdown/util.js +80 -0
  68. package/dist/plugins/util.d.ts +60 -0
  69. package/dist/plugins/util.js +7 -0
  70. package/dist/read/read-space.d.ts +2 -0
  71. package/dist/read/read-space.js +22 -0
  72. package/dist/read/resolve-graph-edges.d.ts +11 -0
  73. package/dist/read/resolve-graph-edges.js +201 -0
  74. package/dist/read/wikilink-utils.d.ts +16 -0
  75. package/dist/read/wikilink-utils.js +38 -0
  76. package/dist/render/registry.d.ts +13 -0
  77. package/dist/render/registry.js +22 -0
  78. package/dist/render/render.d.ts +4 -0
  79. package/dist/render/render.js +28 -0
  80. package/dist/schema/evaluate-rule.d.ts +30 -0
  81. package/dist/schema/evaluate-rule.js +82 -0
  82. package/dist/schema/metadata-contract.d.ts +538 -0
  83. package/dist/schema/metadata-contract.js +115 -0
  84. package/dist/schema/schema-refs.d.ts +22 -0
  85. package/dist/schema/schema-refs.js +168 -0
  86. package/dist/schema/schema.d.ts +27 -0
  87. package/dist/schema/schema.js +378 -0
  88. package/dist/schema/validate-graph.d.ts +24 -0
  89. package/dist/schema/validate-graph.js +141 -0
  90. package/dist/schema/validate-rules.d.ts +10 -0
  91. package/dist/schema/validate-rules.js +51 -0
  92. package/dist/schemas/_ost_strict.json +81 -0
  93. package/dist/schemas/_sctx_base.json +72 -0
  94. package/dist/schemas/general.json +261 -0
  95. package/dist/schemas/generated/_structured_context_schema_meta.json +191 -0
  96. package/dist/schemas/knowledge_wiki.json +206 -0
  97. package/dist/schemas/strict_ost.json +97 -0
  98. package/dist/space-graph.d.ts +28 -0
  99. package/dist/space-graph.js +82 -0
  100. package/dist/types.d.ts +145 -0
  101. package/dist/types.js +0 -0
  102. package/docs/concepts.md +391 -0
  103. package/docs/config.md +140 -0
  104. package/docs/rules.md +120 -0
  105. package/docs/schemas.md +340 -0
  106. package/package.json +69 -0
  107. package/schemas/_ost_strict.json +81 -0
  108. package/schemas/_sctx_base.json +72 -0
  109. package/schemas/general.json +261 -0
  110. package/schemas/generated/_structured_context_schema_meta.json +191 -0
  111. package/schemas/knowledge_wiki.json +206 -0
  112. 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
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ import type { SpaceContext } from './types';
3
+ export declare function buildSpaceContext(spaceName: string): SpaceContext;
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();