@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.
- package/dist/activities/validate.js +8 -4
- package/dist/fgca/parse-canonical.d.ts +14 -2
- package/dist/fgca/parse-canonical.js +31 -8
- package/dist/fgca/resolver.js +16 -0
- package/dist/webview/entry.js +26 -4
- package/dist/webview/render-fgca.d.ts +2 -0
- package/dist/webview/render-fgca.js +8 -2
- package/package.json +3 -3
- package/src/activities/validate.ts +8 -4
- package/src/fgca/parse-canonical.ts +44 -7
- package/src/fgca/resolver.ts +24 -0
- package/src/webview/entry.ts +26 -4
- package/src/webview/render-fgca.ts +12 -1
|
@@ -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 —
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|
package/dist/fgca/resolver.js
CHANGED
|
@@ -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
|
}
|
package/dist/webview/entry.js
CHANGED
|
@@ -113,22 +113,44 @@ function dispatchValidate(kind, doc) {
|
|
|
113
113
|
return r;
|
|
114
114
|
}
|
|
115
115
|
case 'dgca': {
|
|
116
|
-
const
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 —
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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(
|
|
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
|
}
|
package/src/fgca/resolver.ts
CHANGED
|
@@ -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
|
}
|
package/src/webview/entry.ts
CHANGED
|
@@ -187,22 +187,44 @@ function dispatchValidate(kind: NotationKind, doc: unknown): RenderResult {
|
|
|
187
187
|
return r;
|
|
188
188
|
}
|
|
189
189
|
case 'dgca': {
|
|
190
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
}
|