@transitrix/diagrams 1.16.0 → 1.17.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/actor/types.d.ts +1 -1
- package/dist/actor/types.js +2 -0
- package/dist/goals/resolver.d.ts +1 -0
- package/dist/goals/resolver.js +44 -2
- package/dist/repo-validate/check-strategy-chain.js +58 -23
- package/package.json +1 -1
- package/src/actor/types.ts +2 -0
- package/src/goals/resolver.ts +46 -2
- package/src/repo-validate/check-strategy-chain.ts +49 -23
package/dist/actor/types.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type ActorType = typeof ACTOR_TYPES[number];
|
|
|
6
6
|
* inline on an ACTOR file (§7.10, ACTOR-003). They belong in time-aware REL
|
|
7
7
|
* records, never on the identity primitive.
|
|
8
8
|
*/
|
|
9
|
-
export declare const ACTOR_FORBIDDEN_INLINE_FIELDS: readonly ["employment", "candidacy", "alumni_membership", "community_membership", "contracting", "unit_parent", "located_at", "unit_located_at", "roles", "owner"];
|
|
9
|
+
export declare const ACTOR_FORBIDDEN_INLINE_FIELDS: readonly ["employment", "candidacy", "alumni_membership", "community_membership", "contracting", "unit_parent", "reports_to", "manager", "located_at", "unit_located_at", "roles", "owner"];
|
|
10
10
|
export interface Actor {
|
|
11
11
|
notation: 'actor';
|
|
12
12
|
id: string;
|
package/dist/actor/types.js
CHANGED
package/dist/goals/resolver.d.ts
CHANGED
package/dist/goals/resolver.js
CHANGED
|
@@ -42,10 +42,51 @@ function collectGoalElements(docs) {
|
|
|
42
42
|
if (!id)
|
|
43
43
|
continue;
|
|
44
44
|
if (!out.has(id))
|
|
45
|
-
out.set(id, doc); //
|
|
45
|
+
out.set(id, { ...doc }); // never mutate the caller's catalogue
|
|
46
46
|
}
|
|
47
47
|
return out;
|
|
48
48
|
}
|
|
49
|
+
/** Materialize canonical child -> parent links for a single-parent tree.
|
|
50
|
+
* Never mutate canon or silently linearize an ambiguous hierarchy.
|
|
51
|
+
*/
|
|
52
|
+
function applyGoalParentRelations(goals, relations, validAt) {
|
|
53
|
+
const declaredChildren = new Set();
|
|
54
|
+
const parents = new Map();
|
|
55
|
+
for (const doc of relations) {
|
|
56
|
+
if (!isObject(doc) || doc['notation'] !== 'relation' || doc['type'] !== 'goal_parent')
|
|
57
|
+
continue;
|
|
58
|
+
const child = str(doc['from']);
|
|
59
|
+
const parent = str(doc['to']);
|
|
60
|
+
if (!child || !parent || !goals.has(child) || !goals.has(parent)) {
|
|
61
|
+
throw new Error(`goal_parent relation ${str(doc['id']) ?? '(unnamed)'} must reference two existing goals`);
|
|
62
|
+
}
|
|
63
|
+
declaredChildren.add(child);
|
|
64
|
+
const from = str(doc['valid_from']);
|
|
65
|
+
const to = str(doc['valid_to']);
|
|
66
|
+
if (validAt && ((from && validAt < from) || (to && validAt > to)))
|
|
67
|
+
continue;
|
|
68
|
+
const choices = parents.get(child) ?? new Set();
|
|
69
|
+
choices.add(parent);
|
|
70
|
+
parents.set(child, choices);
|
|
71
|
+
}
|
|
72
|
+
for (const child of declaredChildren) {
|
|
73
|
+
const goal = goals.get(child);
|
|
74
|
+
const choices = parents.get(child) ?? new Set();
|
|
75
|
+
if (choices.size > 1) {
|
|
76
|
+
throw new Error(`goal_parent for ${child} has multiple parents in this scope; narrow valid_at or use a graph view`);
|
|
77
|
+
}
|
|
78
|
+
const parent = [...choices][0];
|
|
79
|
+
const legacyParent = str(goal['parent']);
|
|
80
|
+
if (parent && legacyParent && parent !== legacyParent) {
|
|
81
|
+
throw new Error(`goal_parent for ${child} conflicts with its inline parent`);
|
|
82
|
+
}
|
|
83
|
+
// Declared but inactive links must not fall back to an undated parent.
|
|
84
|
+
if (parent)
|
|
85
|
+
goal['parent'] = parent;
|
|
86
|
+
else
|
|
87
|
+
delete goal['parent'];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
49
90
|
/**
|
|
50
91
|
* Returns true when the parsed view document uses the canon-projection form
|
|
51
92
|
* (`view_config` key present, no inline `goals[]`). Used by the preview and
|
|
@@ -69,6 +110,8 @@ export function resolveGoals(viewDoc, sources) {
|
|
|
69
110
|
const vc = isObject(viewDoc['view_config']) ? viewDoc['view_config'] : {};
|
|
70
111
|
const scope = isObject(vc['scope']) ? vc['scope'] : {};
|
|
71
112
|
const allGoals = collectGoalElements(sources.elements);
|
|
113
|
+
const validAt = str(scope['valid_at']);
|
|
114
|
+
applyGoalParentRelations(allGoals, sources.relations ?? [], validAt);
|
|
72
115
|
// 1. Scope by root_goal (this goal + its descendants), or every goal when
|
|
73
116
|
// omitted (§5.1: "Omit to show all root goals").
|
|
74
117
|
const rootGoal = str(scope['root_goal']);
|
|
@@ -97,7 +140,6 @@ export function resolveGoals(viewDoc, sources) {
|
|
|
97
140
|
// 4. Narrow by valid_at — only goals in effect on that date. Goals with no
|
|
98
141
|
// valid_from are not lifecycle-tracked and are kept (permissive default,
|
|
99
142
|
// matching the action/dgca resolvers' stance).
|
|
100
|
-
const validAt = str(scope['valid_at']);
|
|
101
143
|
if (validAt) {
|
|
102
144
|
candidateIds = new Set([...candidateIds].filter((id) => {
|
|
103
145
|
const el = allGoals.get(id);
|
|
@@ -17,16 +17,11 @@ import { validateActionFields } from '../activities/action-fields.js';
|
|
|
17
17
|
// classification (`Issue.Severity` in the Go source) and its "import
|
|
18
18
|
// anyway, record the warning" policy.
|
|
19
19
|
//
|
|
20
|
-
// GOALS-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// this shape and do surface GOALS-011 warnings — that noise is the accepted
|
|
26
|
-
// cost of a *warning* level rule (non-blocking, advisory), unlike the ERROR
|
|
27
|
-
// tier this repo held the line on. This is a deliberate call, not an
|
|
28
|
-
// oversight: keep the coverage rather than drop it, now that it can be
|
|
29
|
-
// non-blocking.
|
|
20
|
+
// GOALS-010 checks canonical and legacy edges in overlapping lifecycle slices.
|
|
21
|
+
// GOALS-009/011 respect the canonical goal_parent records already loaded
|
|
22
|
+
// from canon/relations. Static catalogue coverage considers those links;
|
|
23
|
+
// date-scoped hierarchy selection and ambiguity are checked by Goals views.
|
|
24
|
+
// Legacy inline parents remain supported for transitional catalogues.
|
|
30
25
|
//
|
|
31
26
|
// GOALS-008 (type/level mismatch) is still NOT ported, at either severity.
|
|
32
27
|
// Both of DSM's cases ("type not declared in goal_types" / "level doesn't
|
|
@@ -107,17 +102,15 @@ function collectByNotation(elements, matches) {
|
|
|
107
102
|
}
|
|
108
103
|
return out;
|
|
109
104
|
}
|
|
110
|
-
/** DFS cycle detection over
|
|
105
|
+
/** DFS cycle detection over resolved parent edges, matching DSM's
|
|
111
106
|
* `findCycle` (goals.go): only walks an edge to a parent that itself resolves
|
|
112
107
|
* within the set — an edge into an unresolved (orphan) parent is not walked,
|
|
113
108
|
* so an orphan can never be mistaken for a cycle. Returns the id where a
|
|
114
109
|
* back-edge was hit, or `undefined` if the graph is acyclic. */
|
|
115
|
-
function findParentCycle(goals) {
|
|
116
|
-
const parentOf = new Map();
|
|
110
|
+
function findParentCycle(goals, parents) {
|
|
117
111
|
const known = new Set();
|
|
118
112
|
for (const g of goals) {
|
|
119
113
|
known.add(g.id);
|
|
120
|
-
parentOf.set(g.id, readString(g.data, 'parent'));
|
|
121
114
|
}
|
|
122
115
|
const UNVISITED = 0;
|
|
123
116
|
const VISITING = 1;
|
|
@@ -130,8 +123,9 @@ function findParentCycle(goals) {
|
|
|
130
123
|
if (s === VISITING)
|
|
131
124
|
return id;
|
|
132
125
|
state.set(id, VISITING);
|
|
133
|
-
const parent
|
|
134
|
-
|
|
126
|
+
for (const parent of parents.get(id) ?? []) {
|
|
127
|
+
if (!known.has(parent))
|
|
128
|
+
continue;
|
|
135
129
|
const hit = walk(parent);
|
|
136
130
|
if (hit)
|
|
137
131
|
return hit;
|
|
@@ -184,27 +178,68 @@ function findPredecessorCycle(actions) {
|
|
|
184
178
|
}
|
|
185
179
|
return undefined;
|
|
186
180
|
}
|
|
187
|
-
/** GOALS-010 —
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
181
|
+
/** GOALS-010 — parent links must be acyclic in every effective date slice.
|
|
182
|
+
* A cycle can first appear only when an edge starts. Inspect those boundaries
|
|
183
|
+
* and the unbounded past; do not union disjoint historical parent relations.
|
|
184
|
+
*/
|
|
185
|
+
function checkGoalParentCycle(goals, relations, findings) {
|
|
186
|
+
const known = new Set(goals.map(g => g.id));
|
|
187
|
+
const links = relations.flatMap(rel => {
|
|
188
|
+
const d = rel.data;
|
|
189
|
+
if (!d || d['notation'] !== 'relation' || d['type'] !== 'goal_parent')
|
|
190
|
+
return [];
|
|
191
|
+
const child = readString(d, 'from'), parent = readString(d, 'to');
|
|
192
|
+
if (!child || !parent || !known.has(child) || !known.has(parent))
|
|
193
|
+
return [];
|
|
194
|
+
return [{ child, parent, from: readString(d, 'valid_from'), to: readString(d, 'valid_to') }];
|
|
195
|
+
});
|
|
196
|
+
const declared = new Set(links.map(link => link.child));
|
|
197
|
+
const dates = [...new Set(['0000-01-01', ...links.flatMap(link => link.from ? [link.from] : [])])].sort();
|
|
198
|
+
for (const date of dates) {
|
|
199
|
+
const parents = new Map();
|
|
200
|
+
for (const g of goals) {
|
|
201
|
+
const legacy = readString(g.data, 'parent');
|
|
202
|
+
if (!declared.has(g.id) && legacy)
|
|
203
|
+
parents.set(g.id, [legacy]);
|
|
204
|
+
}
|
|
205
|
+
for (const link of links) {
|
|
206
|
+
if ((link.from && date < link.from) || (link.to && date > link.to))
|
|
207
|
+
continue;
|
|
208
|
+
parents.set(link.child, [...(parents.get(link.child) ?? []), link.parent]);
|
|
209
|
+
}
|
|
210
|
+
const cyc = findParentCycle(goals, parents);
|
|
211
|
+
if (!cyc)
|
|
212
|
+
continue;
|
|
191
213
|
findings.push({
|
|
192
214
|
scope: PScope,
|
|
193
215
|
id: cyc,
|
|
194
216
|
ruleId: 'GOALS-010',
|
|
195
217
|
message: `GOALS-010: parent chain contains a cycle involving goal '${cyc}'.`,
|
|
196
218
|
});
|
|
219
|
+
return;
|
|
197
220
|
}
|
|
198
221
|
}
|
|
199
222
|
/** GOALS-009 / GOALS-011 — GOAL `parent` resolution (warning). Mirrors DSM's
|
|
200
223
|
* goals.go: the two are mutually exclusive per goal — a goal with no
|
|
201
224
|
* `parent` is checked for GOALS-011 (backlog, level >= 1); a goal with a
|
|
202
225
|
* `parent` is checked for GOALS-009 (orphan, parent unresolved) instead. */
|
|
203
|
-
function checkGoalParentResolution(goals, findings) {
|
|
226
|
+
function checkGoalParentResolution(goals, relations, findings) {
|
|
204
227
|
const knownIds = new Set(goals.map((g) => g.id));
|
|
228
|
+
const linkedChildren = new Set();
|
|
229
|
+
for (const rel of relations) {
|
|
230
|
+
const data = rel.data;
|
|
231
|
+
if (!data || data['notation'] !== 'relation' || data['type'] !== 'goal_parent')
|
|
232
|
+
continue;
|
|
233
|
+
const child = readString(data, 'from');
|
|
234
|
+
const parent = readString(data, 'to');
|
|
235
|
+
if (child && parent && knownIds.has(child) && knownIds.has(parent))
|
|
236
|
+
linkedChildren.add(child);
|
|
237
|
+
}
|
|
205
238
|
for (const g of goals) {
|
|
206
239
|
const parent = readString(g.data, 'parent');
|
|
207
240
|
if (!parent) {
|
|
241
|
+
if (linkedChildren.has(g.id))
|
|
242
|
+
continue;
|
|
208
243
|
const level = readFiniteNumber(g.data, 'level');
|
|
209
244
|
if (level !== undefined && level >= 1) {
|
|
210
245
|
findings.push({
|
|
@@ -467,8 +502,8 @@ export function checkStrategyChainSemantics(input, findings) {
|
|
|
467
502
|
const actions = collectByNotation(input.elements, isActionNotation);
|
|
468
503
|
const drivers = collectByNotation(input.elements, isDriverNotation);
|
|
469
504
|
const changes = collectByNotation(input.elements, isChangeNotation);
|
|
470
|
-
checkGoalParentCycle(goals, findings);
|
|
471
|
-
checkGoalParentResolution(goals, findings);
|
|
505
|
+
checkGoalParentCycle(goals, input.relations, findings);
|
|
506
|
+
checkGoalParentResolution(goals, input.relations, findings);
|
|
472
507
|
checkActionSelfPredecessor(actions, findings);
|
|
473
508
|
checkActionPredecessorCycle(actions, findings);
|
|
474
509
|
checkActionOrphanReferences(actions, findings);
|
package/package.json
CHANGED
package/src/actor/types.ts
CHANGED
package/src/goals/resolver.ts
CHANGED
|
@@ -48,6 +48,7 @@ export interface GoalsViewConfig {
|
|
|
48
48
|
|
|
49
49
|
export interface GoalsCanonSources {
|
|
50
50
|
elements: unknown[];
|
|
51
|
+
relations?: unknown[];
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/** GOAL elements by id (`notation: goal`). */
|
|
@@ -58,11 +59,53 @@ function collectGoalElements(docs: unknown[]): Map<string, Record<string, unknow
|
|
|
58
59
|
if (str(doc['notation']) !== 'goal') continue;
|
|
59
60
|
const id = str(doc['id']);
|
|
60
61
|
if (!id) continue;
|
|
61
|
-
if (!out.has(id)) out.set(id, doc); //
|
|
62
|
+
if (!out.has(id)) out.set(id, { ...doc }); // never mutate the caller's catalogue
|
|
62
63
|
}
|
|
63
64
|
return out;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/** Materialize canonical child -> parent links for a single-parent tree.
|
|
68
|
+
* Never mutate canon or silently linearize an ambiguous hierarchy.
|
|
69
|
+
*/
|
|
70
|
+
function applyGoalParentRelations(
|
|
71
|
+
goals: Map<string, Record<string, unknown>>,
|
|
72
|
+
relations: unknown[],
|
|
73
|
+
validAt: string | undefined,
|
|
74
|
+
): void {
|
|
75
|
+
const declaredChildren = new Set<string>();
|
|
76
|
+
const parents = new Map<string, Set<string>>();
|
|
77
|
+
for (const doc of relations) {
|
|
78
|
+
if (!isObject(doc) || doc['notation'] !== 'relation' || doc['type'] !== 'goal_parent') continue;
|
|
79
|
+
const child = str(doc['from']);
|
|
80
|
+
const parent = str(doc['to']);
|
|
81
|
+
if (!child || !parent || !goals.has(child) || !goals.has(parent)) {
|
|
82
|
+
throw new Error(`goal_parent relation ${str(doc['id']) ?? '(unnamed)'} must reference two existing goals`);
|
|
83
|
+
}
|
|
84
|
+
declaredChildren.add(child);
|
|
85
|
+
const from = str(doc['valid_from']);
|
|
86
|
+
const to = str(doc['valid_to']);
|
|
87
|
+
if (validAt && ((from && validAt < from) || (to && validAt > to))) continue;
|
|
88
|
+
const choices = parents.get(child) ?? new Set<string>();
|
|
89
|
+
choices.add(parent);
|
|
90
|
+
parents.set(child, choices);
|
|
91
|
+
}
|
|
92
|
+
for (const child of declaredChildren) {
|
|
93
|
+
const goal = goals.get(child)!;
|
|
94
|
+
const choices = parents.get(child) ?? new Set<string>();
|
|
95
|
+
if (choices.size > 1) {
|
|
96
|
+
throw new Error(`goal_parent for ${child} has multiple parents in this scope; narrow valid_at or use a graph view`);
|
|
97
|
+
}
|
|
98
|
+
const parent = [...choices][0];
|
|
99
|
+
const legacyParent = str(goal['parent']);
|
|
100
|
+
if (parent && legacyParent && parent !== legacyParent) {
|
|
101
|
+
throw new Error(`goal_parent for ${child} conflicts with its inline parent`);
|
|
102
|
+
}
|
|
103
|
+
// Declared but inactive links must not fall back to an undated parent.
|
|
104
|
+
if (parent) goal['parent'] = parent;
|
|
105
|
+
else delete goal['parent'];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
66
109
|
/**
|
|
67
110
|
* Returns true when the parsed view document uses the canon-projection form
|
|
68
111
|
* (`view_config` key present, no inline `goals[]`). Used by the preview and
|
|
@@ -91,6 +134,8 @@ export function resolveGoals(
|
|
|
91
134
|
const scope = isObject(vc['scope']) ? vc['scope'] : {};
|
|
92
135
|
|
|
93
136
|
const allGoals = collectGoalElements(sources.elements);
|
|
137
|
+
const validAt = str(scope['valid_at']);
|
|
138
|
+
applyGoalParentRelations(allGoals, sources.relations ?? [], validAt);
|
|
94
139
|
|
|
95
140
|
// 1. Scope by root_goal (this goal + its descendants), or every goal when
|
|
96
141
|
// omitted (§5.1: "Omit to show all root goals").
|
|
@@ -126,7 +171,6 @@ export function resolveGoals(
|
|
|
126
171
|
// 4. Narrow by valid_at — only goals in effect on that date. Goals with no
|
|
127
172
|
// valid_from are not lifecycle-tracked and are kept (permissive default,
|
|
128
173
|
// matching the action/dgca resolvers' stance).
|
|
129
|
-
const validAt = str(scope['valid_at']);
|
|
130
174
|
if (validAt) {
|
|
131
175
|
candidateIds = new Set(
|
|
132
176
|
[...candidateIds].filter((id) => {
|
|
@@ -17,16 +17,11 @@ import { validateActionFields } from '../activities/action-fields.js';
|
|
|
17
17
|
// classification (`Issue.Severity` in the Go source) and its "import
|
|
18
18
|
// anyway, record the warning" policy.
|
|
19
19
|
//
|
|
20
|
-
// GOALS-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// this shape and do surface GOALS-011 warnings — that noise is the accepted
|
|
26
|
-
// cost of a *warning* level rule (non-blocking, advisory), unlike the ERROR
|
|
27
|
-
// tier this repo held the line on. This is a deliberate call, not an
|
|
28
|
-
// oversight: keep the coverage rather than drop it, now that it can be
|
|
29
|
-
// non-blocking.
|
|
20
|
+
// GOALS-010 checks canonical and legacy edges in overlapping lifecycle slices.
|
|
21
|
+
// GOALS-009/011 respect the canonical goal_parent records already loaded
|
|
22
|
+
// from canon/relations. Static catalogue coverage considers those links;
|
|
23
|
+
// date-scoped hierarchy selection and ambiguity are checked by Goals views.
|
|
24
|
+
// Legacy inline parents remain supported for transitional catalogues.
|
|
30
25
|
//
|
|
31
26
|
// GOALS-008 (type/level mismatch) is still NOT ported, at either severity.
|
|
32
27
|
// Both of DSM's cases ("type not declared in goal_types" / "level doesn't
|
|
@@ -124,17 +119,15 @@ function collectByNotation(
|
|
|
124
119
|
return out;
|
|
125
120
|
}
|
|
126
121
|
|
|
127
|
-
/** DFS cycle detection over
|
|
122
|
+
/** DFS cycle detection over resolved parent edges, matching DSM's
|
|
128
123
|
* `findCycle` (goals.go): only walks an edge to a parent that itself resolves
|
|
129
124
|
* within the set — an edge into an unresolved (orphan) parent is not walked,
|
|
130
125
|
* so an orphan can never be mistaken for a cycle. Returns the id where a
|
|
131
126
|
* back-edge was hit, or `undefined` if the graph is acyclic. */
|
|
132
|
-
function findParentCycle(goals: ChainElement[]): string | undefined {
|
|
133
|
-
const parentOf = new Map<string, string | undefined>();
|
|
127
|
+
function findParentCycle(goals: ChainElement[], parents: Map<string, string[]>): string | undefined {
|
|
134
128
|
const known = new Set<string>();
|
|
135
129
|
for (const g of goals) {
|
|
136
130
|
known.add(g.id);
|
|
137
|
-
parentOf.set(g.id, readString(g.data, 'parent'));
|
|
138
131
|
}
|
|
139
132
|
const UNVISITED = 0;
|
|
140
133
|
const VISITING = 1;
|
|
@@ -146,8 +139,8 @@ function findParentCycle(goals: ChainElement[]): string | undefined {
|
|
|
146
139
|
if (s === DONE) return undefined;
|
|
147
140
|
if (s === VISITING) return id;
|
|
148
141
|
state.set(id, VISITING);
|
|
149
|
-
const parent
|
|
150
|
-
|
|
142
|
+
for (const parent of parents.get(id) ?? []) {
|
|
143
|
+
if (!known.has(parent)) continue;
|
|
151
144
|
const hit = walk(parent);
|
|
152
145
|
if (hit) return hit;
|
|
153
146
|
}
|
|
@@ -198,16 +191,40 @@ function findPredecessorCycle(actions: ChainElement[]): string | undefined {
|
|
|
198
191
|
return undefined;
|
|
199
192
|
}
|
|
200
193
|
|
|
201
|
-
/** GOALS-010 —
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
194
|
+
/** GOALS-010 — parent links must be acyclic in every effective date slice.
|
|
195
|
+
* A cycle can first appear only when an edge starts. Inspect those boundaries
|
|
196
|
+
* and the unbounded past; do not union disjoint historical parent relations.
|
|
197
|
+
*/
|
|
198
|
+
function checkGoalParentCycle(goals: ChainElement[], relations: RepoDoc[], findings: RepoFinding[]): void {
|
|
199
|
+
const known = new Set(goals.map(g => g.id));
|
|
200
|
+
const links = relations.flatMap(rel => {
|
|
201
|
+
const d = rel.data;
|
|
202
|
+
if (!d || d['notation'] !== 'relation' || d['type'] !== 'goal_parent') return [];
|
|
203
|
+
const child = readString(d, 'from'), parent = readString(d, 'to');
|
|
204
|
+
if (!child || !parent || !known.has(child) || !known.has(parent)) return [];
|
|
205
|
+
return [{ child, parent, from: readString(d, 'valid_from'), to: readString(d, 'valid_to') }];
|
|
206
|
+
});
|
|
207
|
+
const declared = new Set(links.map(link => link.child));
|
|
208
|
+
const dates = [...new Set(['0000-01-01', ...links.flatMap(link => link.from ? [link.from] : [])])].sort();
|
|
209
|
+
for (const date of dates) {
|
|
210
|
+
const parents = new Map<string, string[]>();
|
|
211
|
+
for (const g of goals) {
|
|
212
|
+
const legacy = readString(g.data, 'parent');
|
|
213
|
+
if (!declared.has(g.id) && legacy) parents.set(g.id, [legacy]);
|
|
214
|
+
}
|
|
215
|
+
for (const link of links) {
|
|
216
|
+
if ((link.from && date < link.from) || (link.to && date > link.to)) continue;
|
|
217
|
+
parents.set(link.child, [...(parents.get(link.child) ?? []), link.parent]);
|
|
218
|
+
}
|
|
219
|
+
const cyc = findParentCycle(goals, parents);
|
|
220
|
+
if (!cyc) continue;
|
|
205
221
|
findings.push({
|
|
206
222
|
scope: PScope,
|
|
207
223
|
id: cyc,
|
|
208
224
|
ruleId: 'GOALS-010',
|
|
209
225
|
message: `GOALS-010: parent chain contains a cycle involving goal '${cyc}'.`,
|
|
210
226
|
});
|
|
227
|
+
return;
|
|
211
228
|
}
|
|
212
229
|
}
|
|
213
230
|
|
|
@@ -215,11 +232,20 @@ function checkGoalParentCycle(goals: ChainElement[], findings: RepoFinding[]): v
|
|
|
215
232
|
* goals.go: the two are mutually exclusive per goal — a goal with no
|
|
216
233
|
* `parent` is checked for GOALS-011 (backlog, level >= 1); a goal with a
|
|
217
234
|
* `parent` is checked for GOALS-009 (orphan, parent unresolved) instead. */
|
|
218
|
-
function checkGoalParentResolution(goals: ChainElement[], findings: RepoFinding[]): void {
|
|
235
|
+
function checkGoalParentResolution(goals: ChainElement[], relations: RepoDoc[], findings: RepoFinding[]): void {
|
|
219
236
|
const knownIds = new Set(goals.map((g) => g.id));
|
|
237
|
+
const linkedChildren = new Set<string>();
|
|
238
|
+
for (const rel of relations) {
|
|
239
|
+
const data = rel.data;
|
|
240
|
+
if (!data || data['notation'] !== 'relation' || data['type'] !== 'goal_parent') continue;
|
|
241
|
+
const child = readString(data, 'from');
|
|
242
|
+
const parent = readString(data, 'to');
|
|
243
|
+
if (child && parent && knownIds.has(child) && knownIds.has(parent)) linkedChildren.add(child);
|
|
244
|
+
}
|
|
220
245
|
for (const g of goals) {
|
|
221
246
|
const parent = readString(g.data, 'parent');
|
|
222
247
|
if (!parent) {
|
|
248
|
+
if (linkedChildren.has(g.id)) continue;
|
|
223
249
|
const level = readFiniteNumber(g.data, 'level');
|
|
224
250
|
if (level !== undefined && level >= 1) {
|
|
225
251
|
findings.push({
|
|
@@ -504,8 +530,8 @@ export function checkStrategyChainSemantics(input: RepoModelInput, findings: Rep
|
|
|
504
530
|
const drivers = collectByNotation(input.elements, isDriverNotation);
|
|
505
531
|
const changes = collectByNotation(input.elements, isChangeNotation);
|
|
506
532
|
|
|
507
|
-
checkGoalParentCycle(goals, findings);
|
|
508
|
-
checkGoalParentResolution(goals, findings);
|
|
533
|
+
checkGoalParentCycle(goals, input.relations, findings);
|
|
534
|
+
checkGoalParentResolution(goals, input.relations, findings);
|
|
509
535
|
checkActionSelfPredecessor(actions, findings);
|
|
510
536
|
checkActionPredecessorCycle(actions, findings);
|
|
511
537
|
checkActionOrphanReferences(actions, findings);
|