@transitrix/diagrams 1.11.0 → 1.12.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.
@@ -199,8 +199,9 @@ export function validateActivities(input) {
199
199
  }
200
200
  }
201
201
  // ACT-011: warn if no duration (duration_days is accepted as an alias)
202
+ // Network view is unaffected; this is only about CPM/Gantt rendering.
202
203
  if ((act.duration === undefined || act.duration === null) && (act.duration_days === undefined || act.duration_days === null)) {
203
- warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — cannot participate in CPM analysis`, path });
204
+ warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — Gantt view and CPM analysis will exclude it. Network view is unaffected.`, path });
204
205
  }
205
206
  }
206
207
  if (errors.length > 0)
@@ -269,7 +270,9 @@ export function validateActivities(input) {
269
270
  }
270
271
  }
271
272
  }
272
- // ACT-013: orphan activities (warning only)
273
+ // ACT-013: orphan activities (warning only). An activity is structurally orphan
274
+ // if it has no connections to the rest of the graph. Valid connections are:
275
+ // predecessors, successors, parent, or goals (direct or via delivers_changes).
273
276
  const successorIds = new Set();
274
277
  for (const a of doc.activities) {
275
278
  for (const pred of (a.predecessors ?? []))
@@ -280,10 +283,11 @@ export function validateActivities(input) {
280
283
  const hasSuccessors = successorIds.has(a.id);
281
284
  const hasGoals = Array.isArray(a.goals) && a.goals.length > 0;
282
285
  const hasPredecessors = Array.isArray(a.predecessors) && a.predecessors.length > 0;
283
- if (doc.activities.length > 1 && !hasSuccessors && !hasGoals && !hasPredecessors) {
286
+ const hasParent = typeof a.parent === 'string' && a.parent.length > 0;
287
+ if (doc.activities.length > 1 && !hasSuccessors && !hasGoals && !hasPredecessors && !hasParent) {
284
288
  warnings.push({
285
289
  code: 'ACT-013',
286
- message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, or goals)`,
290
+ message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, parent, or goals)`,
287
291
  path: `activities[${i}]`,
288
292
  });
289
293
  }
@@ -28,17 +28,29 @@ import type { ValidationResult } from '../validation-types.js';
28
28
  export interface CanonicalFGCAResult extends ValidationResult {
29
29
  /** On success, the internal-form FGCADoc derived from the canonical input. */
30
30
  parsed?: FGCADoc;
31
+ /** True when a goal-scoped projection should show the scope caption. */
32
+ scopeCaption?: boolean;
31
33
  }
32
34
  /** DGA mode (`view_config.layers.changes: off`) omits the Changes column. */
33
35
  export declare function isDgcaChangesLayerOff(input: unknown): boolean;
34
36
  /**
35
37
  * Validate canonical FGCA YAML and, on success, return an internal-form
36
38
  * `FGCADoc` ready for `buildFGCALayout`.
39
+ *
40
+ * @param input The FGCA document to parse
41
+ * @param outOfScopeGoalIds When a goal-scoped projection omits goals that exist
42
+ * in the catalogue, pass their IDs here to suppress FGCA-011/FGCA-009 errors
43
+ * for references to out-of-scope goals. When provided and non-empty, and at least
44
+ * one action or change references an out-of-scope goal, `scopeCaption` is set true.
45
+ * @param outOfScopeFactorIds When a goal-scoped projection omits factors that exist
46
+ * in the catalogue, pass their IDs here to suppress FGCA-008 errors.
37
47
  */
38
- export declare function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult;
48
+ export declare function parseCanonicalFGCA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null): CanonicalFGCAResult;
39
49
  /** On success, the internal-form FGCADoc derived from canonical FGA input. */
40
50
  export interface CanonicalFGAResult extends ValidationResult {
41
51
  parsed?: FGCADoc;
52
+ /** True when a goal-scoped projection should show the scope caption. */
53
+ scopeCaption?: boolean;
42
54
  }
43
55
  /**
44
56
  * Canonical-form FGA parser + validator.
@@ -57,4 +69,4 @@ export interface CanonicalFGAResult extends ValidationResult {
57
69
  * the canonical FGA path is unit-testable and the library owns the single
58
70
  * canon shape — flat form only, no `fga:` wrapper.
59
71
  */
60
- export declare function parseCanonicalFGA(input: unknown): CanonicalFGAResult;
72
+ export declare function parseCanonicalFGA(input: unknown, outOfScopeGoalIds?: Set<string> | null, outOfScopeFactorIds?: Set<string> | null): CanonicalFGAResult;
@@ -59,8 +59,16 @@ export function isDgcaChangesLayerOff(input) {
59
59
  /**
60
60
  * Validate canonical FGCA YAML and, on success, return an internal-form
61
61
  * `FGCADoc` ready for `buildFGCALayout`.
62
+ *
63
+ * @param input The FGCA document to parse
64
+ * @param outOfScopeGoalIds When a goal-scoped projection omits goals that exist
65
+ * in the catalogue, pass their IDs here to suppress FGCA-011/FGCA-009 errors
66
+ * for references to out-of-scope goals. When provided and non-empty, and at least
67
+ * one action or change references an out-of-scope goal, `scopeCaption` is set true.
68
+ * @param outOfScopeFactorIds When a goal-scoped projection omits factors that exist
69
+ * in the catalogue, pass their IDs here to suppress FGCA-008 errors.
62
70
  */
63
- export function parseCanonicalFGCA(input) {
71
+ export function parseCanonicalFGCA(input, outOfScopeGoalIds, outOfScopeFactorIds) {
64
72
  const errors = [];
65
73
  const warnings = [];
66
74
  if (!input || typeof input !== 'object') {
@@ -173,8 +181,10 @@ export function parseCanonicalFGCA(input) {
173
181
  activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`, ACTION_OR_ACTIVITY_ID_RE, activityIds));
174
182
  if (errors.length > 0)
175
183
  return { valid: false, errors, warnings };
184
+ // Track if any out-of-scope goals/factors are referenced (for scope caption).
185
+ let hasOutOfScopeRef = false;
176
186
  // Cross-refs.
177
- function checkRefArray(el, field, targets, refRe, code, path) {
187
+ function checkRefArray(el, field, targets, refRe, code, path, outOfScope) {
178
188
  const ref = el[field];
179
189
  if (ref === undefined || ref === null)
180
190
  return [];
@@ -192,6 +202,12 @@ export function parseCanonicalFGCA(input) {
192
202
  errors.push({ code: 'FGCA-007', message: `${path}.${field}[] entry "${r}" does not match the expected grammar`, path });
193
203
  continue;
194
204
  }
205
+ if (outOfScope?.has(r)) {
206
+ // Reference is out-of-scope: suppress error and mark that we found an out-of-scope reference (for caption).
207
+ hasOutOfScopeRef = true;
208
+ out.push(r);
209
+ continue;
210
+ }
195
211
  if (!targets.has(r)) {
196
212
  errors.push({ code, message: `${path}.${field}[] references undeclared element "${r}"`, path });
197
213
  continue;
@@ -208,7 +224,7 @@ export function parseCanonicalFGCA(input) {
208
224
  name: String(el['name'] ?? ''),
209
225
  }));
210
226
  const internalGoals = goals.map((el, i) => {
211
- const refIds = checkRefArray(el, 'factors', factorIds, FACTOR_OR_DRIVER_ID_RE, 'FGCA-008', `goals[${i}]`);
227
+ const refIds = checkRefArray(el, 'factors', factorIds, FACTOR_OR_DRIVER_ID_RE, 'FGCA-008', `goals[${i}]`, outOfScopeFactorIds);
212
228
  return {
213
229
  id: String(el['id']),
214
230
  name: String(el['name'] ?? ''),
@@ -233,7 +249,7 @@ export function parseCanonicalFGCA(input) {
233
249
  }
234
250
  }
235
251
  const internalChanges = changes.map((el, i) => {
236
- const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-009', `changes[${i}]`);
252
+ const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-009', `changes[${i}]`, outOfScopeGoalIds);
237
253
  // Internal `change.goal_id` is singular; canonical `change.goals` is plural.
238
254
  // First goal wins for the singular field; warn (not error) if multiple.
239
255
  if (goalRefs.length > 1) {
@@ -253,7 +269,7 @@ export function parseCanonicalFGCA(input) {
253
269
  };
254
270
  });
255
271
  const internalActivities = activities.map((el, i) => {
256
- const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`);
272
+ const goalRefs = checkRefArray(el, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`, outOfScopeGoalIds);
257
273
  const typeVal = typeof el['type'] === 'string' ? el['type'] : undefined;
258
274
  return {
259
275
  id: String(el['id']),
@@ -292,7 +308,13 @@ export function parseCanonicalFGCA(input) {
292
308
  activities: internalActivities,
293
309
  hideChanges: isDgcaChangesLayerOff(raw),
294
310
  };
295
- return { valid: true, errors, warnings, parsed };
311
+ return {
312
+ valid: true,
313
+ errors,
314
+ warnings,
315
+ parsed,
316
+ scopeCaption: (outOfScopeGoalIds?.size ?? 0) > 0 && hasOutOfScopeRef,
317
+ };
296
318
  }
297
319
  const FGA_DOC_ID_RE = /^(FGA|DGA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
298
320
  /**
@@ -312,7 +334,7 @@ const FGA_DOC_ID_RE = /^(FGA|DGA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
312
334
  * the canonical FGA path is unit-testable and the library owns the single
313
335
  * canon shape — flat form only, no `fga:` wrapper.
314
336
  */
315
- export function parseCanonicalFGA(input) {
337
+ export function parseCanonicalFGA(input, outOfScopeGoalIds, outOfScopeFactorIds) {
316
338
  if (!input || typeof input !== 'object') {
317
339
  return { valid: false, errors: [{ code: 'FGA-001', message: 'document root is not an object' }], warnings: [] };
318
340
  }
@@ -339,7 +361,7 @@ export function parseCanonicalFGA(input) {
339
361
  // codes are remapped on the way out. FGCA-009 / 010 / 014 (changes-related)
340
362
  // are unreachable here because `changes` is empty.
341
363
  const synth = { ...raw, notation: 'dgca', id: 'DGCA-FROM-DGA-1', changes: [] };
342
- const r = parseCanonicalFGCA(synth);
364
+ const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds);
343
365
  const remap = {
344
366
  'FGCA-001': 'FGA-001',
345
367
  'FGCA-002': 'FGA-002',
@@ -359,5 +381,6 @@ export function parseCanonicalFGA(input) {
359
381
  errors: r.errors.map((e) => ({ ...e, code: remapCode(e.code) })),
360
382
  warnings: r.warnings.map((w) => ({ ...w, code: remapCode(w.code) })),
361
383
  parsed: r.parsed,
384
+ scopeCaption: r.scopeCaption,
362
385
  };
363
386
  }
@@ -122,6 +122,20 @@ export function resolveFGCA(viewDoc, sources) {
122
122
  selectedActivities = [...activityElems.values()].filter((a) => strArray(a[actionChangeLinkField(a)]).some((cid) => selectedChangeIds.has(cid)) ||
123
123
  strArray(a['goals']).some((gid) => selectedGoalIds.has(gid)));
124
124
  }
125
+ // Collect out-of-scope goals (exist in catalogue but not in selected set).
126
+ // Used by the validator to suppress errors for references to goals that exist
127
+ // but are outside the scope.
128
+ let outOfScopeGoalIds;
129
+ if (goalsFilter !== 'all') {
130
+ // Goal scope is restricted; collect goals that exist but aren't selected.
131
+ outOfScopeGoalIds = new Set([...goalElems.keys()].filter((id) => !selectedGoalIds.has(id)));
132
+ }
133
+ // Collect out-of-scope factors (exist in catalogue but not in selected set).
134
+ const selectedFactorIds = new Set(selectedFactors.map((f) => str(f['id'])).filter((x) => x !== undefined));
135
+ let outOfScopeFactorIds;
136
+ if (goalsFilter !== 'all') {
137
+ outOfScopeFactorIds = new Set([...factorElems.keys()].filter((id) => !selectedFactorIds.has(id)));
138
+ }
125
139
  return {
126
140
  notation: 'dgca',
127
141
  id: viewDoc['id'],
@@ -132,5 +146,7 @@ export function resolveFGCA(viewDoc, sources) {
132
146
  changes: selectedChanges,
133
147
  actions: selectedActivities,
134
148
  view_config: viewDoc['view_config'],
149
+ __outOfScopeGoalIds: outOfScopeGoalIds,
150
+ __outOfScopeFactorIds: outOfScopeFactorIds,
135
151
  };
136
152
  }
@@ -113,22 +113,44 @@ function dispatchValidate(kind, doc) {
113
113
  return r;
114
114
  }
115
115
  case 'dgca': {
116
- const v = parseCanonicalFGCA(doc);
116
+ const meta = (doc ?? {});
117
+ const outOfScopeGoalIds = meta['__outOfScopeGoalIds'] instanceof Set
118
+ ? meta['__outOfScopeGoalIds']
119
+ : Array.isArray(meta['__outOfScopeGoalIds'])
120
+ ? new Set(meta['__outOfScopeGoalIds'])
121
+ : undefined;
122
+ const outOfScopeFactorIds = meta['__outOfScopeFactorIds'] instanceof Set
123
+ ? meta['__outOfScopeFactorIds']
124
+ : Array.isArray(meta['__outOfScopeFactorIds'])
125
+ ? new Set(meta['__outOfScopeFactorIds'])
126
+ : undefined;
127
+ const v = parseCanonicalFGCA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
117
128
  const r = emptyResult(kind, v.valid ? 'ok' : 'error');
118
129
  r.errors.push(...v.errors);
119
130
  r.warnings.push(...v.warnings);
120
131
  if (v.valid && v.parsed) {
121
- r.svg = renderFgcaSvg(v.parsed, { variant: 'dgca' });
132
+ r.svg = renderFgcaSvg(v.parsed, { variant: 'dgca', scopeCaption: v.scopeCaption });
122
133
  }
123
134
  return r;
124
135
  }
125
136
  case 'dga': {
126
- const v = parseCanonicalFGA(doc);
137
+ const meta = (doc ?? {});
138
+ const outOfScopeGoalIds = meta['__outOfScopeGoalIds'] instanceof Set
139
+ ? meta['__outOfScopeGoalIds']
140
+ : Array.isArray(meta['__outOfScopeGoalIds'])
141
+ ? new Set(meta['__outOfScopeGoalIds'])
142
+ : undefined;
143
+ const outOfScopeFactorIds = meta['__outOfScopeFactorIds'] instanceof Set
144
+ ? meta['__outOfScopeFactorIds']
145
+ : Array.isArray(meta['__outOfScopeFactorIds'])
146
+ ? new Set(meta['__outOfScopeFactorIds'])
147
+ : undefined;
148
+ const v = parseCanonicalFGA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
127
149
  const r = emptyResult(kind, v.valid ? 'ok' : 'error');
128
150
  r.errors.push(...v.errors);
129
151
  r.warnings.push(...v.warnings);
130
152
  if (v.valid && v.parsed) {
131
- r.svg = renderFgcaSvg(v.parsed, { variant: 'dga' });
153
+ r.svg = renderFgcaSvg(v.parsed, { variant: 'dga', scopeCaption: v.scopeCaption });
132
154
  }
133
155
  return r;
134
156
  }
@@ -41,6 +41,8 @@ export interface RenderFgcaOptions {
41
41
  entryCurvature?: number;
42
42
  nodeSizePreset?: NodeSizePreset;
43
43
  layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
44
+ /** Show scope caption when goal-scoped projection omits goals that reference out-of-scope goals. */
45
+ scopeCaption?: boolean;
44
46
  }
45
47
  export declare function renderFgcaSvg(doc: FGCADoc, options?: RenderFgcaOptions): string;
46
48
  export {};
@@ -66,7 +66,7 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
66
66
  return `${headerSvg}\n${nodeSvg}\n${edgeSvg}`;
67
67
  }
68
68
  export function renderFgcaSvg(doc, options = {}) {
69
- const { variant = 'dgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, nodeSizePreset = 'normal', layoutOptions, } = options;
69
+ const { variant = 'dgca', title = '', curvature = DEFAULT_EDGE_CURVATURE, entryCurvature, nodeSizePreset = 'normal', layoutOptions, scopeCaption = false, } = options;
70
70
  const hideChanges = variant === 'dga' || doc.hideChanges === true;
71
71
  const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
72
72
  const { nodes, edges, columns, width, height } = layoutFGCAPreview(doc, {
@@ -85,8 +85,13 @@ export function renderFgcaSvg(doc, options = {}) {
85
85
  const titleSvg = title
86
86
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(title)}</text>`
87
87
  : '';
88
+ const captionSvg = scopeCaption
89
+ ? `<text class="text-caption" x="${PAD}" y="${height + 16}">${escXml('This diagram is shown for the selected goal scope. Actions on it may also serve goals outside that scope.')}</text>`
90
+ : '';
88
91
  const embedCss = generateSvgEmbedCss('transitrix');
89
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
92
+ // Calculate final height including caption if present
93
+ const finalHeight = scopeCaption ? height + 24 : height;
94
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${finalHeight}" viewBox="0 0 ${width} ${finalHeight}">
90
95
  <style>${embedCss}</style>
91
96
  <defs>
92
97
  <marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
@@ -95,5 +100,6 @@ export function renderFgcaSvg(doc, options = {}) {
95
100
  </defs>
96
101
  ${titleSvg}
97
102
  ${body}
103
+ ${captionSvg}
98
104
  </svg>`;
99
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -55,12 +55,12 @@
55
55
  "@testing-library/dom": "^10.4.1",
56
56
  "@testing-library/react": "^16.3.2",
57
57
  "@types/react": "^19.2.17",
58
- "@types/react-dom": "^19.2.4",
58
+ "@types/react-dom": "^19.2.5",
59
59
  "jsdom": "^29.1.1",
60
60
  "react": "^19.2.8",
61
61
  "react-dom": "^19.2.8",
62
62
  "reactflow": "^11.11.4",
63
63
  "typescript": "^5.0.0",
64
- "vitest": "^4.1.10"
64
+ "vitest": "^4.1.11"
65
65
  }
66
66
  }
@@ -216,8 +216,9 @@ export function validateActivities(input: unknown): ActivityValidationResult {
216
216
  }
217
217
 
218
218
  // ACT-011: warn if no duration (duration_days is accepted as an alias)
219
+ // Network view is unaffected; this is only about CPM/Gantt rendering.
219
220
  if ((act.duration === undefined || act.duration === null) && (act.duration_days === undefined || act.duration_days === null)) {
220
- warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — cannot participate in CPM analysis`, path });
221
+ warnings.push({ code: 'ACT-011', message: `Activity "${id}" has no duration — Gantt view and CPM analysis will exclude it. Network view is unaffected.`, path });
221
222
  }
222
223
  }
223
224
 
@@ -292,7 +293,9 @@ export function validateActivities(input: unknown): ActivityValidationResult {
292
293
  }
293
294
  }
294
295
 
295
- // ACT-013: orphan activities (warning only)
296
+ // ACT-013: orphan activities (warning only). An activity is structurally orphan
297
+ // if it has no connections to the rest of the graph. Valid connections are:
298
+ // predecessors, successors, parent, or goals (direct or via delivers_changes).
296
299
  const successorIds = new Set<string>();
297
300
  for (const a of doc.activities) {
298
301
  for (const pred of (a.predecessors ?? [])) successorIds.add(pred);
@@ -302,10 +305,11 @@ export function validateActivities(input: unknown): ActivityValidationResult {
302
305
  const hasSuccessors = successorIds.has(a.id);
303
306
  const hasGoals = Array.isArray(a.goals) && a.goals.length > 0;
304
307
  const hasPredecessors = Array.isArray(a.predecessors) && a.predecessors.length > 0;
305
- if (doc.activities.length > 1 && !hasSuccessors && !hasGoals && !hasPredecessors) {
308
+ const hasParent = typeof a.parent === 'string' && a.parent.length > 0;
309
+ if (doc.activities.length > 1 && !hasSuccessors && !hasGoals && !hasPredecessors && !hasParent) {
306
310
  warnings.push({
307
311
  code: 'ACT-013',
308
- message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, or goals)`,
312
+ message: `Activity "${a.id}" appears to be structurally orphan (no predecessors, successors, parent, or goals)`,
309
313
  path: `activities[${i}]`,
310
314
  });
311
315
  }
@@ -41,6 +41,8 @@ import { actionChangeLinkField } from './action-change-ids.js';
41
41
  export interface CanonicalFGCAResult extends ValidationResult {
42
42
  /** On success, the internal-form FGCADoc derived from the canonical input. */
43
43
  parsed?: FGCADoc;
44
+ /** True when a goal-scoped projection should show the scope caption. */
45
+ scopeCaption?: boolean;
44
46
  }
45
47
 
46
48
  // Canonical ID grammars per IDS_AND_REFERENCES.md §1. The TYPE prefix
@@ -79,8 +81,20 @@ export function isDgcaChangesLayerOff(input: unknown): boolean {
79
81
  /**
80
82
  * Validate canonical FGCA YAML and, on success, return an internal-form
81
83
  * `FGCADoc` ready for `buildFGCALayout`.
84
+ *
85
+ * @param input The FGCA document to parse
86
+ * @param outOfScopeGoalIds When a goal-scoped projection omits goals that exist
87
+ * in the catalogue, pass their IDs here to suppress FGCA-011/FGCA-009 errors
88
+ * for references to out-of-scope goals. When provided and non-empty, and at least
89
+ * one action or change references an out-of-scope goal, `scopeCaption` is set true.
90
+ * @param outOfScopeFactorIds When a goal-scoped projection omits factors that exist
91
+ * in the catalogue, pass their IDs here to suppress FGCA-008 errors.
82
92
  */
83
- export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
93
+ export function parseCanonicalFGCA(
94
+ input: unknown,
95
+ outOfScopeGoalIds?: Set<string> | null,
96
+ outOfScopeFactorIds?: Set<string> | null,
97
+ ): CanonicalFGCAResult {
84
98
  const errors: ValidationError[] = [];
85
99
  const warnings: ValidationWarning[] = [];
86
100
 
@@ -204,6 +218,9 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
204
218
 
205
219
  if (errors.length > 0) return { valid: false, errors, warnings };
206
220
 
221
+ // Track if any out-of-scope goals/factors are referenced (for scope caption).
222
+ let hasOutOfScopeRef = false;
223
+
207
224
  // Cross-refs.
208
225
  function checkRefArray(
209
226
  el: Record<string, unknown>,
@@ -212,6 +229,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
212
229
  refRe: RegExp,
213
230
  code: string,
214
231
  path: string,
232
+ outOfScope?: Set<string> | null,
215
233
  ): string[] {
216
234
  const ref = el[field];
217
235
  if (ref === undefined || ref === null) return [];
@@ -229,6 +247,12 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
229
247
  errors.push({ code: 'FGCA-007', message: `${path}.${field}[] entry "${r}" does not match the expected grammar`, path });
230
248
  continue;
231
249
  }
250
+ if (outOfScope?.has(r)) {
251
+ // Reference is out-of-scope: suppress error and mark that we found an out-of-scope reference (for caption).
252
+ hasOutOfScopeRef = true;
253
+ out.push(r);
254
+ continue;
255
+ }
232
256
  if (!targets.has(r)) {
233
257
  errors.push({ code, message: `${path}.${field}[] references undeclared element "${r}"`, path });
234
258
  continue;
@@ -247,7 +271,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
247
271
  }));
248
272
 
249
273
  const internalGoals: GoalItem[] = goals.map((el, i) => {
250
- const refIds = checkRefArray(el!, 'factors', factorIds, FACTOR_OR_DRIVER_ID_RE, 'FGCA-008', `goals[${i}]`);
274
+ const refIds = checkRefArray(el!, 'factors', factorIds, FACTOR_OR_DRIVER_ID_RE, 'FGCA-008', `goals[${i}]`, outOfScopeFactorIds);
251
275
  return {
252
276
  id: String(el!['id']),
253
277
  name: String(el!['name'] ?? ''),
@@ -275,7 +299,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
275
299
  }
276
300
 
277
301
  const internalChanges: BdnChangeWithActivities[] = changes.map((el, i) => {
278
- const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-009', `changes[${i}]`);
302
+ const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-009', `changes[${i}]`, outOfScopeGoalIds);
279
303
  // Internal `change.goal_id` is singular; canonical `change.goals` is plural.
280
304
  // First goal wins for the singular field; warn (not error) if multiple.
281
305
  if (goalRefs.length > 1) {
@@ -296,7 +320,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
296
320
  });
297
321
 
298
322
  const internalActivities: ActivityItem[] = activities.map((el, i) => {
299
- const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`);
323
+ const goalRefs = checkRefArray(el!, 'goals', goalIds, GOAL_ID_RE, 'FGCA-011', `actions[${i}]`, outOfScopeGoalIds);
300
324
  const typeVal = typeof el!['type'] === 'string' ? el!['type'] : undefined;
301
325
  return {
302
326
  id: String(el!['id']),
@@ -337,7 +361,13 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
337
361
  hideChanges: isDgcaChangesLayerOff(raw),
338
362
  };
339
363
 
340
- return { valid: true, errors, warnings, parsed };
364
+ return {
365
+ valid: true,
366
+ errors,
367
+ warnings,
368
+ parsed,
369
+ scopeCaption: (outOfScopeGoalIds?.size ?? 0) > 0 && hasOutOfScopeRef,
370
+ };
341
371
  }
342
372
 
343
373
  const FGA_DOC_ID_RE = /^(FGA|DGA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
@@ -345,6 +375,8 @@ const FGA_DOC_ID_RE = /^(FGA|DGA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
345
375
  /** On success, the internal-form FGCADoc derived from canonical FGA input. */
346
376
  export interface CanonicalFGAResult extends ValidationResult {
347
377
  parsed?: FGCADoc;
378
+ /** True when a goal-scoped projection should show the scope caption. */
379
+ scopeCaption?: boolean;
348
380
  }
349
381
 
350
382
  /**
@@ -364,7 +396,11 @@ export interface CanonicalFGAResult extends ValidationResult {
364
396
  * the canonical FGA path is unit-testable and the library owns the single
365
397
  * canon shape — flat form only, no `fga:` wrapper.
366
398
  */
367
- export function parseCanonicalFGA(input: unknown): CanonicalFGAResult {
399
+ export function parseCanonicalFGA(
400
+ input: unknown,
401
+ outOfScopeGoalIds?: Set<string> | null,
402
+ outOfScopeFactorIds?: Set<string> | null,
403
+ ): CanonicalFGAResult {
368
404
  if (!input || typeof input !== 'object') {
369
405
  return { valid: false, errors: [{ code: 'FGA-001', message: 'document root is not an object' }], warnings: [] };
370
406
  }
@@ -392,7 +428,7 @@ export function parseCanonicalFGA(input: unknown): CanonicalFGAResult {
392
428
  // codes are remapped on the way out. FGCA-009 / 010 / 014 (changes-related)
393
429
  // are unreachable here because `changes` is empty.
394
430
  const synth = { ...raw, notation: 'dgca', id: 'DGCA-FROM-DGA-1', changes: [] };
395
- const r = parseCanonicalFGCA(synth);
431
+ const r = parseCanonicalFGCA(synth, outOfScopeGoalIds, outOfScopeFactorIds);
396
432
  const remap: Record<string, string> = {
397
433
  'FGCA-001': 'FGA-001',
398
434
  'FGCA-002': 'FGA-002',
@@ -412,5 +448,6 @@ export function parseCanonicalFGA(input: unknown): CanonicalFGAResult {
412
448
  errors: r.errors.map((e) => ({ ...e, code: remapCode(e.code) })),
413
449
  warnings: r.warnings.map((w) => ({ ...w, code: remapCode(w.code) })),
414
450
  parsed: r.parsed,
451
+ scopeCaption: r.scopeCaption,
415
452
  };
416
453
  }
@@ -158,6 +158,28 @@ export function resolveFGCA(
158
158
  );
159
159
  }
160
160
 
161
+ // Collect out-of-scope goals (exist in catalogue but not in selected set).
162
+ // Used by the validator to suppress errors for references to goals that exist
163
+ // but are outside the scope.
164
+ let outOfScopeGoalIds: Set<string> | undefined;
165
+ if (goalsFilter !== 'all') {
166
+ // Goal scope is restricted; collect goals that exist but aren't selected.
167
+ outOfScopeGoalIds = new Set(
168
+ [...goalElems.keys()].filter((id) => !selectedGoalIds.has(id)),
169
+ );
170
+ }
171
+
172
+ // Collect out-of-scope factors (exist in catalogue but not in selected set).
173
+ const selectedFactorIds = new Set(
174
+ selectedFactors.map((f) => str(f['id'])).filter((x): x is string => x !== undefined),
175
+ );
176
+ let outOfScopeFactorIds: Set<string> | undefined;
177
+ if (goalsFilter !== 'all') {
178
+ outOfScopeFactorIds = new Set(
179
+ [...factorElems.keys()].filter((id) => !selectedFactorIds.has(id)),
180
+ );
181
+ }
182
+
161
183
  return {
162
184
  notation: 'dgca',
163
185
  id: viewDoc['id'],
@@ -168,5 +190,7 @@ export function resolveFGCA(
168
190
  changes: selectedChanges,
169
191
  actions: selectedActivities,
170
192
  view_config: viewDoc['view_config'],
193
+ __outOfScopeGoalIds: outOfScopeGoalIds,
194
+ __outOfScopeFactorIds: outOfScopeFactorIds,
171
195
  };
172
196
  }
@@ -187,22 +187,44 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
187
187
  return r;
188
188
  }
189
189
  case 'dgca': {
190
- const v = parseCanonicalFGCA(doc);
190
+ const meta = (doc ?? {}) as Record<string, unknown>;
191
+ const outOfScopeGoalIds = meta['__outOfScopeGoalIds'] instanceof Set
192
+ ? (meta['__outOfScopeGoalIds'] as Set<string>)
193
+ : Array.isArray(meta['__outOfScopeGoalIds'])
194
+ ? new Set(meta['__outOfScopeGoalIds'] as string[])
195
+ : undefined;
196
+ const outOfScopeFactorIds = meta['__outOfScopeFactorIds'] instanceof Set
197
+ ? (meta['__outOfScopeFactorIds'] as Set<string>)
198
+ : Array.isArray(meta['__outOfScopeFactorIds'])
199
+ ? new Set(meta['__outOfScopeFactorIds'] as string[])
200
+ : undefined;
201
+ const v = parseCanonicalFGCA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
191
202
  const r = emptyResult(kind, v.valid ? 'ok' : 'error');
192
203
  r.errors.push(...v.errors);
193
204
  r.warnings.push(...v.warnings);
194
205
  if (v.valid && v.parsed) {
195
- r.svg = renderFgcaSvg(v.parsed, { variant: 'dgca' });
206
+ r.svg = renderFgcaSvg(v.parsed, { variant: 'dgca', scopeCaption: v.scopeCaption });
196
207
  }
197
208
  return r;
198
209
  }
199
210
  case 'dga': {
200
- const v = parseCanonicalFGA(doc);
211
+ const meta = (doc ?? {}) as Record<string, unknown>;
212
+ const outOfScopeGoalIds = meta['__outOfScopeGoalIds'] instanceof Set
213
+ ? (meta['__outOfScopeGoalIds'] as Set<string>)
214
+ : Array.isArray(meta['__outOfScopeGoalIds'])
215
+ ? new Set(meta['__outOfScopeGoalIds'] as string[])
216
+ : undefined;
217
+ const outOfScopeFactorIds = meta['__outOfScopeFactorIds'] instanceof Set
218
+ ? (meta['__outOfScopeFactorIds'] as Set<string>)
219
+ : Array.isArray(meta['__outOfScopeFactorIds'])
220
+ ? new Set(meta['__outOfScopeFactorIds'] as string[])
221
+ : undefined;
222
+ const v = parseCanonicalFGA(doc, outOfScopeGoalIds, outOfScopeFactorIds);
201
223
  const r = emptyResult(kind, v.valid ? 'ok' : 'error');
202
224
  r.errors.push(...v.errors);
203
225
  r.warnings.push(...v.warnings);
204
226
  if (v.valid && v.parsed) {
205
- r.svg = renderFgcaSvg(v.parsed, { variant: 'dga' });
227
+ r.svg = renderFgcaSvg(v.parsed, { variant: 'dga', scopeCaption: v.scopeCaption });
206
228
  }
207
229
  return r;
208
230
  }
@@ -110,6 +110,8 @@ export interface RenderFgcaOptions {
110
110
  entryCurvature?: number;
111
111
  nodeSizePreset?: NodeSizePreset;
112
112
  layoutOptions?: Parameters<typeof layoutFGCAPreview>[1];
113
+ /** Show scope caption when goal-scoped projection omits goals that reference out-of-scope goals. */
114
+ scopeCaption?: boolean;
113
115
  }
114
116
 
115
117
  export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): string {
@@ -120,6 +122,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
120
122
  entryCurvature,
121
123
  nodeSizePreset = 'normal',
122
124
  layoutOptions,
125
+ scopeCaption = false,
123
126
  } = options;
124
127
  const hideChanges = variant === 'dga' || doc.hideChanges === true;
125
128
  const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
@@ -144,9 +147,16 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
144
147
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(title)}</text>`
145
148
  : '';
146
149
 
150
+ const captionSvg = scopeCaption
151
+ ? `<text class="text-caption" x="${PAD}" y="${height + 16}">${escXml('This diagram is shown for the selected goal scope. Actions on it may also serve goals outside that scope.')}</text>`
152
+ : '';
153
+
147
154
  const embedCss = generateSvgEmbedCss('transitrix');
148
155
 
149
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
156
+ // Calculate final height including caption if present
157
+ const finalHeight = scopeCaption ? height + 24 : height;
158
+
159
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${finalHeight}" viewBox="0 0 ${width} ${finalHeight}">
150
160
  <style>${embedCss}</style>
151
161
  <defs>
152
162
  <marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="3" orient="auto">
@@ -155,5 +165,6 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
155
165
  </defs>
156
166
  ${titleSvg}
157
167
  ${body}
168
+ ${captionSvg}
158
169
  </svg>`;
159
170
  }