structured-context 0.13.1 → 0.14.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 (49) hide show
  1. package/README.md +2 -2
  2. package/dist/commands/dump.js +1 -1
  3. package/dist/commands/validate.js +8 -8
  4. package/dist/filter/augment-nodes.d.ts +5 -4
  5. package/dist/filter/augment-nodes.js +20 -19
  6. package/dist/filter/expand-include.d.ts +2 -2
  7. package/dist/filter/expand-include.js +15 -17
  8. package/dist/filter/filter-nodes.js +2 -2
  9. package/dist/judge/context.js +12 -12
  10. package/dist/judge/judge-node.d.ts +3 -3
  11. package/dist/judge/judge-node.js +7 -7
  12. package/dist/judge/prompt.js +3 -3
  13. package/dist/judge/provider/fake.d.ts +1 -1
  14. package/dist/judge/provider/fake.js +3 -6
  15. package/dist/judge/provider/jev.js +1 -1
  16. package/dist/judge/provider/openai-decisions.js +1 -1
  17. package/dist/judge/types.d.ts +4 -1
  18. package/dist/plugins/markdown/index.d.ts +6 -0
  19. package/dist/plugins/markdown/index.js +1 -0
  20. package/dist/plugins/markdown/parse-embedded.d.ts +7 -2
  21. package/dist/plugins/markdown/parse-embedded.js +23 -21
  22. package/dist/plugins/markdown/read-space.js +9 -8
  23. package/dist/plugins/markdown/render-bullets.js +3 -3
  24. package/dist/plugins/markdown/util.d.ts +11 -0
  25. package/dist/plugins/markdown/util.js +21 -0
  26. package/dist/plugins/mermaid/render-mermaid.js +5 -6
  27. package/dist/plugins/miro/cache.js +1 -1
  28. package/dist/plugins/miro/sync.js +3 -2
  29. package/dist/read/resolve-graph-edges.js +12 -18
  30. package/dist/schema/evaluate-rule.d.ts +4 -4
  31. package/dist/schema/evaluate-rule.js +14 -10
  32. package/dist/schema/gating.d.ts +10 -10
  33. package/dist/schema/gating.js +45 -44
  34. package/dist/schema/validate-graph.js +8 -11
  35. package/dist/schema/validate-rules.d.ts +4 -4
  36. package/dist/schema/validate-rules.js +15 -17
  37. package/dist/schemas/_ost_strict.json +1 -1
  38. package/dist/space-graph.d.ts +9 -9
  39. package/dist/space-graph.js +16 -16
  40. package/dist/testing.d.ts +1 -1
  41. package/dist/testing.js +3 -3
  42. package/dist/types.d.ts +14 -10
  43. package/dist/validate.d.ts +2 -2
  44. package/dist/validate.js +18 -19
  45. package/docs/concepts.md +25 -7
  46. package/docs/config.md +8 -0
  47. package/docs/rules.md +17 -13
  48. package/package.json +1 -1
  49. package/schemas/_ost_strict.json +1 -1
@@ -2,8 +2,8 @@ import { judgeNodes } from '../judge/judge-node';
2
2
  import { buildEvalContext, evaluateRuleExpression, expressionErrorViolation, tryEvaluateExpression, } from './evaluate-rule';
3
3
  import { ruleSeverities, ruleSeverity } from './rule-severity';
4
4
  /**
5
- * Whether a failure of this rule gates its subtree: explicit `gatesDescendants`, else true only for `error` severity.
6
- * `severity` is that of the failure: the rule's own for an expression, the verdict's for a semantic part.
5
+ * Whether a violation of this rule gates its subtree: explicit `gatesDescendants`, else true only for `error` severity.
6
+ * `severity` is that of the violation: the rule's own for an expression, the verdict's for a semantic part.
7
7
  */
8
8
  export function ruleGatesDescendants(rule, severity = ruleSeverity(rule)) {
9
9
  return rule.gatesDescendants ?? severity === 'error';
@@ -20,17 +20,17 @@ export function computeHierarchyDepths(graph) {
20
20
  const depths = new Map();
21
21
  const queue = [];
22
22
  for (const node of [...graph.hierarchyRoots, ...graph.orphans]) {
23
- queue.push({ title: node.title, depth: 1 });
23
+ queue.push({ id: node.id, depth: 1 });
24
24
  }
25
25
  let i = 0;
26
26
  while (i < queue.length) {
27
- const { title, depth } = queue[i++];
28
- if (depths.has(title))
27
+ const { id, depth } = queue[i++];
28
+ if (depths.has(id))
29
29
  continue;
30
- depths.set(title, depth);
31
- for (const child of graph.hierarchyChildren.get(title) ?? []) {
32
- if (!depths.has(child.title))
33
- queue.push({ title: child.title, depth: depth + 1 });
30
+ depths.set(id, depth);
31
+ for (const child of graph.hierarchyChildren.get(id) ?? []) {
32
+ if (!depths.has(child.id))
33
+ queue.push({ id: child.id, depth: depth + 1 });
34
34
  }
35
35
  }
36
36
  return depths;
@@ -48,24 +48,24 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
48
48
  const includeGated = options.includeGated ?? false;
49
49
  const nodeIndex = new Map();
50
50
  for (const node of nodes) {
51
- if (node.title)
52
- nodeIndex.set(node.title, node);
51
+ if (node.id)
52
+ nodeIndex.set(node.id, node);
53
53
  }
54
54
  // When a node has both a schema and hierarchy failure, report schema as the reason.
55
- const seedByTitle = new Map();
55
+ const seedById = new Map();
56
56
  for (const seed of seeds) {
57
- if (!seedByTitle.has(seed.title) || seed.reason === 'schema') {
58
- seedByTitle.set(seed.title, seed);
57
+ if (!seedById.has(seed.id) || seed.reason === 'schema') {
58
+ seedById.set(seed.id, seed);
59
59
  }
60
60
  }
61
61
  const judge = options.provider;
62
62
  const violations = [];
63
63
  const gateOrigins = new Map();
64
- function originFor(title, seed) {
65
- let origin = gateOrigins.get(title);
64
+ function originFor(id, seed) {
65
+ let origin = gateOrigins.get(id);
66
66
  if (!origin) {
67
- origin = { file: seed.file, nodeTitle: title, reason: seed.reason, ruleId: seed.ruleId, gatedFiles: [] };
68
- gateOrigins.set(title, origin);
67
+ origin = { file: seed.file, nodeId: id, reason: seed.reason, ruleId: seed.ruleId, gatedFiles: [] };
68
+ gateOrigins.set(id, origin);
69
69
  }
70
70
  return origin;
71
71
  }
@@ -85,10 +85,10 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
85
85
  const parentsOf = new Map();
86
86
  for (const [parent, children] of graph.hierarchyChildren) {
87
87
  for (const child of children)
88
- parentsOf.set(child.title, [...(parentsOf.get(child.title) ?? []), parent]);
88
+ parentsOf.set(child.id, [...(parentsOf.get(child.id) ?? []), parent]);
89
89
  }
90
- const target = nodes.find((n) => n.label === options.judgeTarget);
91
- const pending = target ? [target.title] : [];
90
+ const target = nodes.find((n) => n.id === options.judgeTarget);
91
+ const pending = target ? [target.id] : [];
92
92
  while (pending.length > 0) {
93
93
  for (const parent of parentsOf.get(pending.pop()) ?? []) {
94
94
  if (!targetAncestors.has(parent)) {
@@ -104,12 +104,9 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
104
104
  for (const r of perNodeRules) {
105
105
  if (r.type && r.type !== node.resolvedType)
106
106
  continue;
107
- if (judge &&
108
- r.semantic !== undefined &&
109
- options.judgeTarget !== undefined &&
110
- node.label !== options.judgeTarget) {
107
+ if (judge && r.semantic !== undefined && options.judgeTarget !== undefined && node.id !== options.judgeTarget) {
111
108
  // Ancestors only matter through the gates they could raise.
112
- if (!(targetAncestors.has(node.title) && ruleCanGate(r)))
109
+ if (!(targetAncestors.has(node.id) && ruleCanGate(r)))
113
110
  continue;
114
111
  }
115
112
  if (r.when) {
@@ -128,11 +125,11 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
128
125
  async function prepareNode(node) {
129
126
  const local = [];
130
127
  const prepared = { violations: local, semantic: [], judged: () => { } };
131
- const applicable = await applicableRules(node, (rule, error) => local.push(expressionErrorViolation(rule, node.label, error)));
128
+ const applicable = await applicableRules(node, (rule, error) => local.push(expressionErrorViolation(rule, node.id, error)));
132
129
  function report(rule, detail = {}) {
133
130
  const severity = detail.severity ?? ruleSeverities(rule)[0] ?? ruleSeverity(rule);
134
131
  local.push({
135
- file: node.label,
132
+ file: node.id,
136
133
  ruleId: rule.id,
137
134
  category: rule.category,
138
135
  severity,
@@ -148,7 +145,9 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
148
145
  prepared.blockingRuleId = rule.id;
149
146
  }
150
147
  }
151
- // Cheap expressions first; a semantic part is only evaluated when its own expression passes.
148
+ // Cheap expressions first. A semantic part is evaluated only when its own expression passes, and not at all
149
+ // on a node with a gating violation.
150
+ const semantic = [];
152
151
  for (const rule of applicable) {
153
152
  if (rule.expression !== undefined) {
154
153
  const result = await evaluateRuleExpression(rule, node, nodes, nodeIndex);
@@ -161,11 +160,13 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
161
160
  }
162
161
  }
163
162
  if (rule.semantic !== undefined && judge)
164
- prepared.semantic.push(rule);
163
+ semantic.push(rule);
165
164
  }
165
+ if (!prepared.blockingRuleId)
166
+ prepared.semantic = semantic;
166
167
  prepared.judged = (judgements) => {
167
168
  for (const j of judgements) {
168
- if (j.failed) {
169
+ if (j.violated) {
169
170
  report(j.rule, {
170
171
  severity: j.severity,
171
172
  verdict: j.verdict,
@@ -209,38 +210,38 @@ export async function evaluateRulesWithGating(nodes, rules, graph, seeds, option
209
210
  while (depthNodes.length > 0) {
210
211
  const current = [];
211
212
  for (const entry of depthNodes) {
212
- if (visited.has(entry.node.title))
213
+ if (visited.has(entry.node.id))
213
214
  continue;
214
- visited.add(entry.node.title);
215
+ visited.add(entry.node.id);
215
216
  current.push(entry);
216
217
  }
217
- const isGated = (e) => e.ancestorOrigin !== undefined || seedByTitle.has(e.node.title);
218
+ const isGated = (e) => e.ancestorOrigin !== undefined || seedById.has(e.node.id);
218
219
  const toEvaluate = current.filter((e) => !isGated(e) || includeGated);
219
220
  const evaluated = await evaluateNodeSet(toEvaluate.map((e) => e.node));
220
- const blockingByTitle = new Map(toEvaluate.map((e, i) => [e.node.title, evaluated[i].blockingRuleId]));
221
+ const blockingById = new Map(toEvaluate.map((e, i) => [e.node.id, evaluated[i].blockingRuleId]));
221
222
  const next = [];
222
223
  for (const { node, ancestorOrigin } of current) {
223
- const selfSeed = seedByTitle.get(node.title);
224
+ const selfSeed = seedById.get(node.id);
224
225
  const isGatedByAncestor = ancestorOrigin !== undefined;
225
226
  const isSelfBlocked = selfSeed !== undefined;
226
- const blockingRuleId = blockingByTitle.get(node.title);
227
+ const blockingRuleId = blockingById.get(node.id);
227
228
  let childOrigin = ancestorOrigin;
228
229
  if (isGatedByAncestor) {
229
- gateOrigins.get(ancestorOrigin).gatedFiles.push(node.label);
230
+ gateOrigins.get(ancestorOrigin).gatedFiles.push(node.id);
230
231
  }
231
232
  else if (isSelfBlocked) {
232
- const origin = originFor(node.title, { file: selfSeed.file, reason: selfSeed.reason });
233
- origin.gatedFiles.push(node.label);
234
- childOrigin = node.title;
233
+ const origin = originFor(node.id, { file: selfSeed.file, reason: selfSeed.reason });
234
+ origin.gatedFiles.push(node.id);
235
+ childOrigin = node.id;
235
236
  }
236
237
  else if (blockingRuleId) {
237
- originFor(node.title, { file: node.label, reason: 'rule', ruleId: blockingRuleId });
238
- childOrigin = node.title;
238
+ originFor(node.id, { file: node.id, reason: 'rule', ruleId: blockingRuleId });
239
+ childOrigin = node.id;
239
240
  }
240
241
  else {
241
242
  childOrigin = undefined;
242
243
  }
243
- for (const child of graph.hierarchyChildren.get(node.title) ?? []) {
244
+ for (const child of graph.hierarchyChildren.get(node.id) ?? []) {
244
245
  next.push({ node: child, ancestorOrigin: childOrigin });
245
246
  }
246
247
  }
@@ -5,7 +5,7 @@ import { resolveNodeType } from '../schema/schema';
5
5
  */
6
6
  export function validateGraph(nodes, metadata, unresolvedRefs = []) {
7
7
  const refErrors = unresolvedRefs.map((u) => ({
8
- file: u.source?.file ?? u.label,
8
+ file: u.source?.file ?? u.id,
9
9
  ...(u.source?.line !== undefined ? { line: u.source.line } : {}),
10
10
  parent: u.ref,
11
11
  error: u.message,
@@ -69,20 +69,17 @@ export function validateHierarchyStructure(nodes, metadata) {
69
69
  }
70
70
  }
71
71
  const nodeIndex = new Map();
72
- for (const node of nodes) {
73
- const title = node.title;
74
- if (title)
75
- nodeIndex.set(title, node);
76
- }
72
+ for (const node of nodes)
73
+ nodeIndex.set(node.id, node);
77
74
  for (const node of nodes) {
78
75
  const nodeType = node.resolvedType;
79
76
  const nodeTitle = node.title;
80
77
  for (const parentRef of node.resolvedParents) {
81
- const parentNode = nodeIndex.get(parentRef.title);
78
+ const parentNode = nodeIndex.get(parentRef.id);
82
79
  if (!parentNode)
83
80
  continue;
84
81
  const parentType = parentNode.resolvedType;
85
- const parentTitle = parentRef.title;
82
+ const parentTitle = parentNode.title;
86
83
  if (parentRef.fieldOn === 'parent') {
87
84
  // Field is on the parent node; validate that this node's type matches the expected child type.
88
85
  // Violation is attributed to the field-owner (parent) node.
@@ -90,7 +87,7 @@ export function validateHierarchyStructure(nodes, metadata) {
90
87
  if (allowedChildTypes && !allowedChildTypes.has(nodeType)) {
91
88
  const expected = [...allowedChildTypes].join(' or ');
92
89
  violations.push({
93
- file: parentNode.label,
90
+ file: parentNode.id,
94
91
  nodeType: parentType,
95
92
  nodeTitle: parentTitle,
96
93
  parentType: nodeType,
@@ -105,7 +102,7 @@ export function validateHierarchyStructure(nodes, metadata) {
105
102
  if (allowedTypes && !allowedTypes.has(parentType)) {
106
103
  const expected = [...allowedTypes].join(' or ');
107
104
  violations.push({
108
- file: node.label,
105
+ file: node.id,
109
106
  nodeType,
110
107
  nodeTitle,
111
108
  parentType,
@@ -143,7 +140,7 @@ export function validateHierarchyStructure(nodes, metadata) {
143
140
  }
144
141
  if (!isValidHierarchy) {
145
142
  violations.push({
146
- file: node.label,
143
+ file: node.id,
147
144
  nodeType,
148
145
  nodeTitle,
149
146
  parentType,
@@ -1,14 +1,14 @@
1
1
  import type { TrimReport } from '../judge/trim';
2
2
  import type { SemanticJudgeProvider } from '../judge/types';
3
- import type { Rule, RuleViolation, SpaceNode } from '../types';
3
+ import type { NodeId, Rule, RuleViolation, SpaceNode } from '../types';
4
4
  export { DEFAULT_SEVERITY_BY_CATEGORY, ruleSeverity } from './rule-severity';
5
5
  export interface ValidateRulesOptions {
6
6
  /** Evaluates semantic rules. Without one, a rule runs only its `expression`, and a semantic-only rule is skipped. */
7
7
  judge?: SemanticJudgeProvider;
8
- /** Label of the only node to judge (`validate-file`); other nodes' semantic rules are skipped. */
8
+ /** Id of the only node to judge (`validate-file`); other nodes' semantic rules are skipped. */
9
9
  judgeTarget?: string;
10
- /** Set of node labels that have already failed upstream structural checks. */
11
- gatedNodeLabels?: Set<string>;
10
+ /** Set of node ids that have already failed upstream structural checks. */
11
+ gatedNodeIds?: Set<NodeId>;
12
12
  trimReport?: TrimReport;
13
13
  }
14
14
  /**
@@ -1,6 +1,7 @@
1
1
  import { judgeNodes } from '../judge/judge-node';
2
2
  import { buildSpaceGraph } from '../space-graph';
3
3
  import { buildEvalContext, evaluateRuleExpression, expressionErrorViolation, tryEvaluateExpression, } from './evaluate-rule';
4
+ import { ruleGatesDescendants } from './gating';
4
5
  import { ruleSeverities, ruleSeverity } from './rule-severity';
5
6
  export { DEFAULT_SEVERITY_BY_CATEGORY, ruleSeverity } from './rule-severity';
6
7
  /**
@@ -18,10 +19,7 @@ export async function validateRules(nodes, rules, options = {}) {
18
19
  // Build node index for efficient lookups
19
20
  const nodeIndex = new Map();
20
21
  for (const node of nodes) {
21
- const title = node.title;
22
- if (title) {
23
- nodeIndex.set(title, node);
24
- }
22
+ nodeIndex.set(node.id, node);
25
23
  }
26
24
  const sentinel = nodes[0];
27
25
  const runnable = rules.filter((r) => r.expression !== undefined || (r.semantic !== undefined && judge));
@@ -37,7 +35,7 @@ export async function validateRules(nodes, rules, options = {}) {
37
35
  if (rule.when) {
38
36
  const when = await tryEvaluateExpression(rule.when, buildEvalContext(node, nodes, nodeIndex));
39
37
  if ('error' in when) {
40
- violations.push(expressionErrorViolation(rule, node.label, when.error));
38
+ violations.push(expressionErrorViolation(rule, node.id, when.error));
41
39
  continue;
42
40
  }
43
41
  if (when.value !== true)
@@ -49,8 +47,8 @@ export async function validateRules(nodes, rules, options = {}) {
49
47
  }
50
48
  // 1. Expressions, in rule order. A global rule is evaluated once against the full node set; a sentinel node
51
49
  // provides the evaluation context (the nodes array is what matters).
52
- const failedNodeLabels = new Set(options.gatedNodeLabels); // nodes that failed an expression (or upstream)
53
- const notEvaluable = new Set(); // `${rule.id}\0${label}` whose own expression failed or errored
50
+ const gatedNodeIds = new Set(options.gatedNodeIds); // nodes with a gating violation (or gated upstream)
51
+ const notEvaluable = new Set(); // `${rule.id}\0${id}` whose own expression failed or errored
54
52
  for (const rule of runnable) {
55
53
  if (rule.expression === undefined)
56
54
  continue;
@@ -67,24 +65,24 @@ export async function validateRules(nodes, rules, options = {}) {
67
65
  if (result.passed)
68
66
  continue;
69
67
  violations.push(result.violation);
70
- notEvaluable.add(`${rule.id}\0${node.label}`);
71
- if (result.failed)
72
- failedNodeLabels.add(node.label);
68
+ notEvaluable.add(`${rule.id}\0${node.id}`);
69
+ if (result.failed && ruleGatesDescendants(rule, result.violation.severity))
70
+ gatedNodeIds.add(node.id);
73
71
  }
74
72
  }
75
- // 2. Semantic parts, for nodes that passed their own expression. A node that failed an expression or
76
- // upstream is left out (safe cascading default).
73
+ // 2. Semantic parts, for nodes that passed their own expression. A node with a gating violation, or gated
74
+ // upstream, is left out.
77
75
  if (judge) {
78
76
  const perNode = []; // kept in node order
79
77
  const pending = [];
80
78
  for (const node of nodes) {
81
- if (options.judgeTarget !== undefined && node.label !== options.judgeTarget)
79
+ if (options.judgeTarget !== undefined && node.id !== options.judgeTarget)
82
80
  continue;
83
81
  const found = [];
84
82
  perNode.push(found);
85
- if (failedNodeLabels.has(node.label))
83
+ if (gatedNodeIds.has(node.id))
86
84
  continue;
87
- const semantic = runnable.filter((r) => r.semantic !== undefined && applicable.get(r)?.includes(node) && !notEvaluable.has(`${r.id}\0${node.label}`));
85
+ const semantic = runnable.filter((r) => r.semantic !== undefined && applicable.get(r)?.includes(node) && !notEvaluable.has(`${r.id}\0${node.id}`));
88
86
  if (semantic.length > 0)
89
87
  pending.push({ node, rules: semantic, found });
90
88
  }
@@ -92,10 +90,10 @@ export async function validateRules(nodes, rules, options = {}) {
92
90
  const judged = await judgeNodes(judge, pending, buildSpaceGraph(nodes, undefined), options.trimReport);
93
91
  pending.forEach(({ node, found }, i) => {
94
92
  for (const j of judged[i] ?? []) {
95
- if (!j.failed)
93
+ if (!j.violated)
96
94
  continue;
97
95
  found.push({
98
- file: node.label,
96
+ file: node.id,
99
97
  ruleId: j.rule.id,
100
98
  category: j.rule.category,
101
99
  severity: j.severity ?? ruleSeverities(j.rule)[0] ?? ruleSeverity(j.rule),
@@ -34,7 +34,7 @@
34
34
  "category": "best-practice",
35
35
  "description": "Explore multiple candidate solutions (aim for at least three) for the target opportunity",
36
36
  "type": "opportunity",
37
- "expression": "(current.status != 'exploring' and current.status != 'active') or $count(nodes[resolvedParentTitle=$$.current.title and resolvedType='solution']) >= 3"
37
+ "expression": "(current.status != 'exploring' and current.status != 'active') or $count(nodes[resolvedParentId=$$.current.id and resolvedType='solution']) >= 3"
38
38
  },
39
39
  {
40
40
  "id": "opportunity-customer-voice",
@@ -1,4 +1,4 @@
1
- import type { Hierarchy, SpaceNode } from './types';
1
+ import type { Hierarchy, NodeId, SpaceNode } from './types';
2
2
  /**
3
3
  * A navigable graph over a set of SpaceNodes.
4
4
  *
@@ -9,20 +9,20 @@ import type { Hierarchy, SpaceNode } from './types';
9
9
  * so consumers don't need to build their own indexes.
10
10
  */
11
11
  export type SpaceGraph = {
12
- /** All nodes, keyed by title. Preserves insertion order. */
13
- readonly nodes: ReadonlyMap<string, SpaceNode>;
12
+ /** All nodes, keyed by id. Preserves insertion order. */
13
+ readonly nodes: ReadonlyMap<NodeId, SpaceNode>;
14
14
  /** Hierarchy roots: nodes of the root type with no valid hierarchy parents. */
15
15
  readonly hierarchyRoots: readonly SpaceNode[];
16
16
  /** Orphans: hierarchy-typed nodes with no valid hierarchy parents in the node set. */
17
17
  readonly orphans: readonly SpaceNode[];
18
18
  /** Non-hierarchy nodes: type not in the hierarchy levels definition. */
19
19
  readonly nonHierarchy: readonly SpaceNode[];
20
- /** Hierarchy children map: parent title → direct children connected via this hierarchy's edges only. */
21
- readonly hierarchyChildren: ReadonlyMap<string, readonly SpaceNode[]>;
22
- /** All-edges children map: parent title → direct children connected via any edge (hierarchy + relationship). */
23
- readonly children: ReadonlyMap<string, readonly SpaceNode[]>;
24
- /** Set of all node titles that are part of the hierarchy (roots + their descendants + orphans). */
25
- readonly hierarchyTitles: ReadonlySet<string>;
20
+ /** Hierarchy children map: parent id → direct children connected via this hierarchy's edges only. */
21
+ readonly hierarchyChildren: ReadonlyMap<NodeId, readonly SpaceNode[]>;
22
+ /** All-edges children map: parent id → direct children connected via any edge (hierarchy + relationship). */
23
+ readonly children: ReadonlyMap<NodeId, readonly SpaceNode[]>;
24
+ /** Set of all node ids that are part of the hierarchy (roots + their descendants + orphans). */
25
+ readonly hierarchyIds: ReadonlySet<NodeId>;
26
26
  /** The hierarchy used to build this graph, if the schema has one. */
27
27
  readonly hierarchy: Hierarchy | undefined;
28
28
  };
@@ -12,22 +12,22 @@ export function buildSpaceGraph(nodes, hierarchy) {
12
12
  const nonHierarchy = [];
13
13
  // First pass: register all nodes and init adjacency lists
14
14
  for (const node of nodes) {
15
- nodesMap.set(node.title, node);
16
- hierarchyChildrenMap.set(node.title, []);
17
- childrenMap.set(node.title, []);
15
+ nodesMap.set(node.id, node);
16
+ hierarchyChildrenMap.set(node.id, []);
17
+ childrenMap.set(node.id, []);
18
18
  }
19
19
  // Second pass: build children maps (inverted from resolvedParents)
20
20
  for (const node of nodes) {
21
21
  for (const parentRef of node.resolvedParents) {
22
22
  // All-edges map
23
- if (!childrenMap.has(parentRef.title))
24
- childrenMap.set(parentRef.title, []);
25
- childrenMap.get(parentRef.title).push(node);
23
+ if (!childrenMap.has(parentRef.id))
24
+ childrenMap.set(parentRef.id, []);
25
+ childrenMap.get(parentRef.id).push(node);
26
26
  // Hierarchy-only map
27
27
  if (isHierarchyEdge(parentRef)) {
28
- if (!hierarchyChildrenMap.has(parentRef.title))
29
- hierarchyChildrenMap.set(parentRef.title, []);
30
- hierarchyChildrenMap.get(parentRef.title).push(node);
28
+ if (!hierarchyChildrenMap.has(parentRef.id))
29
+ hierarchyChildrenMap.set(parentRef.id, []);
30
+ hierarchyChildrenMap.get(parentRef.id).push(node);
31
31
  }
32
32
  }
33
33
  }
@@ -50,20 +50,20 @@ export function buildSpaceGraph(nodes, hierarchy) {
50
50
  }
51
51
  else {
52
52
  // Check if at least one hierarchy parent is actually in the node set
53
- const hasValidParent = hierarchyParents.some((r) => nodesMap.has(r.title));
53
+ const hasValidParent = hierarchyParents.some((r) => nodesMap.has(r.id));
54
54
  if (!hasValidParent) {
55
55
  // All hierarchy parents are dangling — treat as orphan
56
56
  orphans.push(node);
57
57
  }
58
58
  }
59
59
  }
60
- // Build hierarchyTitles: BFS from roots + orphans through hierarchyChildren
61
- const hierarchyTitles = new Set();
60
+ // Build hierarchyIds: BFS from roots + orphans through hierarchyChildren
61
+ const hierarchyIds = new Set();
62
62
  function addHierarchySubtree(node) {
63
- if (hierarchyTitles.has(node.title))
63
+ if (hierarchyIds.has(node.id))
64
64
  return;
65
- hierarchyTitles.add(node.title);
66
- for (const child of hierarchyChildrenMap.get(node.title) ?? []) {
65
+ hierarchyIds.add(node.id);
66
+ for (const child of hierarchyChildrenMap.get(node.id) ?? []) {
67
67
  addHierarchySubtree(child);
68
68
  }
69
69
  }
@@ -78,7 +78,7 @@ export function buildSpaceGraph(nodes, hierarchy) {
78
78
  nonHierarchy,
79
79
  hierarchyChildren: hierarchyChildrenMap,
80
80
  children: childrenMap,
81
- hierarchyTitles,
81
+ hierarchyIds,
82
82
  hierarchy,
83
83
  };
84
84
  }
package/dist/testing.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { BaseNode, Hierarchy, HierarchyLevel, Relationship, ResolvedParentR
3
3
  * Creates a ResolvedParentRef with sensible defaults for use in tests.
4
4
  * Override any field to represent specific edge contexts.
5
5
  */
6
- export declare const makeParentRef: (title: string, overrides?: Partial<ResolvedParentRef>) => ResolvedParentRef;
6
+ export declare const makeParentRef: (id: string, overrides?: Partial<ResolvedParentRef>) => ResolvedParentRef;
7
7
  /**
8
8
  * Creates a HierarchyLevel with defaults matching schema metadata normalization.
9
9
  */
package/dist/testing.js CHANGED
@@ -2,10 +2,10 @@
2
2
  * Creates a ResolvedParentRef with sensible defaults for use in tests.
3
3
  * Override any field to represent specific edge contexts.
4
4
  */
5
- export const makeParentRef = (title, overrides = {}) => {
5
+ export const makeParentRef = (id, overrides = {}) => {
6
6
  const source = overrides.source ?? 'hierarchy';
7
7
  return {
8
- title,
8
+ id: id,
9
9
  field: 'parent',
10
10
  source,
11
11
  ...(source === 'hierarchy' ? { hierarchy: 'main' } : {}),
@@ -55,7 +55,7 @@ export const makeRelationship = (parent, type, overrides = {}) => ({
55
55
  * Creates a BaseNode with sensible defaults for use in tests.
56
56
  */
57
57
  export const makeNode = (title, type, extra = {}, linkTargets) => ({
58
- label: `${title}.md`,
58
+ id: title,
59
59
  title,
60
60
  schemaData: { title, type, ...extra },
61
61
  linkTargets: linkTargets ?? [title],
package/dist/types.d.ts CHANGED
@@ -28,14 +28,18 @@ export type Relationship = Omit<MetadataContractRelationship, 'field' | 'fieldOn
28
28
  fieldOn: 'child' | 'parent';
29
29
  multiple: boolean;
30
30
  };
31
+ /** Unique key of a node within a space. Supplied by the plugin that produced the node. */
32
+ export type NodeId = string & {
33
+ readonly __brand: 'NodeId';
34
+ };
31
35
  /**
32
- * A resolved parent reference, capturing not just the parent title but the edge context
36
+ * A resolved parent reference, capturing not just the parent id but the edge context
33
37
  * from which it was resolved. All hierarchy levels and relationships produce entries of
34
38
  * this type, forming a single labelled graph rather than separate structures.
35
39
  */
36
40
  export type ResolvedParentRef = {
37
- /** Canonical title of the parent node. */
38
- title: string;
41
+ /** Id of the parent node. */
42
+ id: NodeId;
39
43
  /** The field name that contained the wikilink (e.g. 'parent', 'key_activities', 'produces_data'). */
40
44
  field: string;
41
45
  /** Whether this edge originates from a hierarchy level or a relationship definition. */
@@ -56,8 +60,8 @@ export type SourceLocation = {
56
60
  column?: number;
57
61
  };
58
62
  export type UnresolvedRef = {
59
- /** Source identifier of the node containing the broken link. */
60
- label: string;
63
+ /** Id of the node containing the broken link. */
64
+ id: NodeId;
61
65
  /** Raw wikilink value (or String(rawField) for invalid_shape). */
62
66
  ref: string;
63
67
  /** Field name that contained the link. */
@@ -109,9 +113,9 @@ export type ResolvedContentLink = ContentLinkBase & {
109
113
  * Core enriches this into a SpaceNode after parsing.
110
114
  */
111
115
  export type BaseNode = {
112
- /** Source identifier for error messages (filename or heading title) */
113
- label: string;
114
- /** Canonical title of the node (from schemaData.title). First-class accessor. */
116
+ /** Unique key of the node: graph key, parent lookup, gating, duplicate detection and the file in messages. Supplied by the plugin. */
117
+ id: NodeId;
118
+ /** Display title (from schemaData.title). May repeat across nodes. */
115
119
  title: string;
116
120
  /** Fields validated against the active schema. */
117
121
  schemaData: Record<string, unknown>;
@@ -138,7 +142,7 @@ export type SpaceNode = BaseNode & {
138
142
  resolvedType: string;
139
143
  /**
140
144
  * Resolved parent references derived from all edge fields (hierarchy levels + relationships).
141
- * Each entry carries the parent title, the field it came from, and its edge context.
145
+ * Each entry carries the parent id, the field it came from, and its edge context.
142
146
  * Always present, empty if no parents resolved.
143
147
  */
144
148
  resolvedParents: ResolvedParentRef[];
@@ -174,7 +178,7 @@ export type RuleSeverity = 'error' | 'warning' | 'info';
174
178
  export type { CoverageGate, GatingSeed } from './schema/gating';
175
179
  export type { Rule } from './schema/metadata-contract';
176
180
  export type RuleViolation = {
177
- /** Node label, or empty string for global-scope rules. */
181
+ /** Node id, or empty string for global-scope rules. */
178
182
  file: string;
179
183
  ruleId: string;
180
184
  category: RuleCategory;
@@ -3,7 +3,7 @@ import type { Config, SpaceConfig } from './config';
3
3
  import type { SemanticJudgeProvider } from './judge/types';
4
4
  import { type RuleFilter } from './schema/rule-filter';
5
5
  import { extractEntityInfo } from './schema/schema';
6
- import type { CoverageGate, FileNotInSpaceResult, FileValidationResult, GraphViolation, ParseIssue, ReadSpaceResult, RuleViolation, SchemaWithMetadata, SpaceContext, SpaceNode, ValidateFileOutput } from './types';
6
+ import type { CoverageGate, FileNotInSpaceResult, FileValidationResult, GraphViolation, NodeId, ParseIssue, ReadSpaceResult, RuleViolation, SchemaWithMetadata, SpaceContext, SpaceNode, ValidateFileOutput } from './types';
7
7
  export interface FormattedError {
8
8
  message: string;
9
9
  dedupeKey: string;
@@ -24,7 +24,7 @@ export interface ValidationResult {
24
24
  error: string;
25
25
  }>;
26
26
  duplicateErrors: Array<{
27
- title: string;
27
+ id: NodeId;
28
28
  files: string[];
29
29
  }>;
30
30
  /** All rule violations with their effective severity. Only `error` severity fails validation. */