@transitrix/diagrams 1.15.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/compliance/catalogue.d.ts +17 -0
  2. package/dist/compliance/catalogue.js +68 -0
  3. package/dist/compliance/index.d.ts +4 -2
  4. package/dist/compliance/index.js +2 -1
  5. package/dist/compliance/requirement-chain.d.ts +13 -2
  6. package/dist/compliance/requirement-chain.js +60 -33
  7. package/dist/goals/resolver.d.ts +1 -0
  8. package/dist/goals/resolver.js +44 -2
  9. package/dist/products/types.d.ts +1 -1
  10. package/dist/products/validate.js +1 -1
  11. package/dist/repo-validate/check-strategy-chain.js +58 -23
  12. package/dist/standalone/validate.js +64 -3
  13. package/dist/webview/render-activities.d.ts +6 -0
  14. package/dist/webview/render-activities.js +2 -2
  15. package/dist/webview/render-activity-card.d.ts +6 -0
  16. package/dist/webview/render-activity-card.js +2 -2
  17. package/dist/webview/render-blocks.d.ts +10 -0
  18. package/dist/webview/render-blocks.js +4 -4
  19. package/dist/webview/render-fgca.d.ts +6 -0
  20. package/dist/webview/render-fgca.js +2 -2
  21. package/dist/webview/render-goals.d.ts +5 -0
  22. package/dist/webview/render-goals.js +9 -2
  23. package/dist/webview/render-process-blueprint.d.ts +6 -0
  24. package/dist/webview/render-process-blueprint.js +2 -2
  25. package/dist/webview/render-products.js +1 -0
  26. package/dist/yaml-normalize.d.ts +3 -1
  27. package/dist/yaml-normalize.js +11 -3
  28. package/package.json +1 -1
  29. package/src/compliance/catalogue.ts +49 -0
  30. package/src/compliance/index.ts +5 -2
  31. package/src/compliance/requirement-chain.ts +52 -27
  32. package/src/goals/resolver.ts +46 -2
  33. package/src/products/types.ts +1 -1
  34. package/src/products/validate.ts +1 -1
  35. package/src/repo-validate/check-strategy-chain.ts +49 -23
  36. package/src/standalone/validate.ts +38 -2
  37. package/src/webview/render-activities.ts +8 -2
  38. package/src/webview/render-activity-card.ts +8 -3
  39. package/src/webview/render-blocks.ts +14 -4
  40. package/src/webview/render-fgca.ts +8 -2
  41. package/src/webview/render-goals.ts +21 -2
  42. package/src/webview/render-process-blueprint.ts +8 -3
  43. package/src/webview/render-products.ts +1 -0
  44. package/src/yaml-normalize.ts +12 -3
@@ -7,7 +7,8 @@ import { validateVerification } from '../verification/validate.js';
7
7
 
8
8
  export const CHAIN_STAGES = ['Source document', 'Driver', 'Need', 'Stakeholder requirement',
9
9
  'System requirement', 'Software requirement', 'Unclassified requirement', 'Verification definition', 'Result'] as const;
10
- export interface ChainScope { catalogue: string; product?: string; release?: string; project?: string }
10
+ export interface ChainSubject { type: 'PRODUCT' | 'APPLICATION'; id: string }
11
+ export interface ChainScope { catalogue: string; subject?: ChainSubject; product?: string; release?: string; project?: string }
11
12
  export interface ChainInput {
12
13
  index: ComplianceIndex; scope: ChainScope; asAt: string; snapshotId: string;
13
14
  sourceRevision?: string; complete: boolean;
@@ -23,7 +24,7 @@ export interface RequirementChainEdge {
23
24
  }
24
25
  export interface ChainAssignment { requirement: string; nearest: string; depth: number; relations: string[] }
25
26
  export interface RequirementChainProjection {
26
- contract: 'requirement-chain/0.2'; contractRevision: string; asAt: string; snapshotId: string; sourceRevision?: string;
27
+ contract: 'requirement-chain/0.2' | 'requirement-chain/0.3'; contractRevision: string; asAt: string; snapshotId: string; sourceRevision?: string;
27
28
  scope: ChainScope; completeness: 'complete' | 'incomplete'; scopeFindings: string[];
28
29
  populations: { product: ChainSet; release: ChainSet; selected: ChainSet; inactive: ChainSet; unresolved: ChainSet };
29
30
  assignments: { here: ChainSet; otherReleaseOnly: ChainSet; unassigned: ChainSet; invalid: ChainSet; provenance: ChainAssignment[] };
@@ -53,9 +54,26 @@ function freeze<T>(value: T): T {
53
54
  const CODEX = ['LAW', 'REGULATION', 'STANDARD', 'POLICY', 'INTERNAL_STANDARD', 'PRINCIPLE'];
54
55
  const FIELD = ['INTERVIEW', 'SURVEY', 'OBSERVATION', 'DRAFT'];
55
56
 
56
- /** Pure, lossless release projection; no legacy coverage verdict is reinterpreted. */
57
+ /** Explicit PRODUCT compatibility adapter for requirement-chain/0.2. */
57
58
  export function buildRequirementChain(input: ChainInput): RequirementChainProjection {
59
+ if (input.scope.subject) throw new Error('Use buildSubjectRequirementChain for explicit subjects');
60
+ return projectRequirementChain(input);
61
+ }
62
+
63
+ /** Subject-aware 0.3 entry point; both versions use the same deterministic projection. */
64
+ export function buildSubjectRequirementChain(input: ChainInput & { scope: ChainScope & { subject: ChainSubject } }): RequirementChainProjection {
65
+ if (input.scope.product !== undefined) throw new Error('Conflicting product and subject selectors');
66
+ if (!['PRODUCT', 'APPLICATION'].includes(input.scope.subject.type)) throw new Error('Unsupported subject type');
67
+ return projectRequirementChain(input);
68
+ }
69
+
70
+ /** Pure, lossless release projection; no legacy coverage verdict is reinterpreted. */
71
+ function projectRequirementChain(input: ChainInput): RequirementChainProjection {
58
72
  const { index, asAt, scope } = input;
73
+ const subject = scope.subject ?? { type: 'PRODUCT', id: scope.product };
74
+ const membershipKind = subject.type === 'APPLICATION' ? 'application_scope' : 'product_scope';
75
+ const pairKind = subject.type === 'APPLICATION' ? 'project_application' : 'project_product';
76
+ const subjectWord = scope.subject ? 'subject' : 'product';
59
77
  const findings = structuredClone(index.findings);
60
78
  const scopeFindings: string[] = [];
61
79
  const records = new Map<string, ChainRecord>();
@@ -64,7 +82,7 @@ export function buildRequirementChain(input: ChainInput): RequirementChainProjec
64
82
  const id = `${owner}.${field}`;
65
83
  if (!findings.some(f => f.id === id && f.code === code)) findings.push({ id, owner, field, code, message, reference, severity: warning ? 'warning' : 'error' });
66
84
  };
67
- for (const [id, versions] of index.recordsById) {
85
+ for (const [id, versions] of [...index.recordsById].sort(([a], [b]) => a.localeCompare(b))) {
68
86
  if (versions.length !== 1) {
69
87
  finding(id, 'id', 'DUPLICATE', 'Ambiguous catalogue identity'); complete = false;
70
88
  } else records.set(id, structuredClone(versions[0]));
@@ -114,7 +132,7 @@ export function buildRequirementChain(input: ChainInput): RequirementChainProjec
114
132
  if (r.raw.level !== undefined && level < 0) finding(r.id, 'level', 'REQ-LEVEL', 'Invalid level; displayed as unclassified');
115
133
  } else if (r.type === 'NEED') node(r, 2);
116
134
  else if (r.type === 'DRIVER') node(r, 1);
117
- else if (['PRODUCT', 'RELEASE', 'ACTION'].includes(r.type)) node(r, -1);
135
+ else if (['PRODUCT', 'APPLICATION', 'RELEASE', 'ACTION'].includes(r.type)) node(r, -1);
118
136
  else if (CODEX.includes(r.type) || FIELD.includes(r.type)) {
119
137
  node(r, 0);
120
138
  if (r.raw.source_document !== undefined) {
@@ -162,6 +180,8 @@ export function buildRequirementChain(input: ChainInput): RequirementChainProjec
162
180
  const src = records.get(from), dst = records.get(to);
163
181
  const endpoints = kind === 'requirement_parent' ? is(from, 'REQUIREMENT') && is(to, 'REQUIREMENT') :
164
182
  kind === 'serves' ? is(from, 'REQUIREMENT') && is(to, 'NEED') :
183
+ kind === 'application_scope' ? is(from, 'REQUIREMENT') && is(to, 'APPLICATION') :
184
+ kind === 'project_application' ? isProject(from) && is(to, 'APPLICATION') :
165
185
  kind === 'product_scope' ? is(from, 'REQUIREMENT') && is(to, 'PRODUCT') :
166
186
  kind === 'project_scope' ? is(from, 'REQUIREMENT') && isProject(to) :
167
187
  kind === 'project_product' ? isProject(from) && is(to, 'PRODUCT') :
@@ -198,38 +218,38 @@ export function buildRequirementChain(input: ChainInput): RequirementChainProjec
198
218
  }
199
219
  }
200
220
  const memberships = (id: string, kind: string) => rels.filter(r => usable.has(r.id) && r.raw.type === kind && r.raw.from === id).map(r => String(r.raw.to));
201
- const unresolved = requirements.filter(r => active.get(r.id) && memberships(r.id, 'product_scope').length === 0).map(r => r.id);
202
- const P = requirements.filter(r => active.get(r.id) && memberships(r.id, 'product_scope').includes(scope.product ?? '')).map(r => r.id);
221
+ const unresolved = requirements.filter(r => active.get(r.id) && memberships(r.id, 'product_scope').length === 0 && (!scope.subject || memberships(r.id, 'application_scope').length === 0)).map(r => r.id);
222
+ const P = requirements.filter(r => active.get(r.id) && memberships(r.id, membershipKind).includes(subject.id ?? '')).map(r => r.id);
203
223
  let productComplete = complete && unresolved.length === 0;
204
- if (unresolved.length) scopeFindings.push('Unresolved product membership');
205
- if (requirements.some(r => !envelope.get(r.id)) || rels.some(r => r.raw.type === 'product_scope' && active.get(r.id) && !usable.has(r.id))) productComplete = false;
224
+ if (unresolved.length) scopeFindings.push(`Unresolved ${subjectWord} membership`);
225
+ if (requirements.some(r => !envelope.get(r.id)) || rels.some(r => r.raw.type === membershipKind && active.get(r.id) && !usable.has(r.id))) productComplete = false;
206
226
  const invalidAssignments = new Set<string>();
207
227
  for (const r of rels.filter(r => r.raw.type === 'required_for')) {
208
228
  const from = String(r.raw.from), to = records.get(String(r.raw.to));
209
229
  const historical = chainDate(r.raw.valid_to) && r.raw.valid_to < asAt;
210
230
  const future = chainDate(r.raw.valid_from) && r.raw.valid_from > asAt;
211
- if (!historical && !future && (!usable.has(r.id) || !memberships(from, 'product_scope').includes(String(to?.raw.of)))) {
231
+ if (!historical && !future && (!usable.has(r.id) || ![...memberships(from, 'product_scope'), ...(scope.subject ? memberships(from, 'application_scope') : [])].includes(String(to?.raw.of)))) {
212
232
  invalidAssignments.add(from);
213
- finding(r.id, 'to', 'ASSIGNMENT', 'Invalid, dangling or wrong-product assignment', true);
233
+ finding(r.id, 'to', 'ASSIGNMENT', `Invalid, dangling or wrong-${subjectWord} assignment`, true);
214
234
  if (!envelope.get(r.id)) productComplete = false;
215
235
  }
216
236
  }
217
237
  let selectedComplete = complete;
218
- if (!is(scope.product, 'PRODUCT')) { productComplete = false; selectedComplete = false; scopeFindings.push('Product unselected or unresolved'); }
219
- if (!is(scope.release, 'RELEASE') || records.get(scope.release ?? '')?.raw.of !== scope.product) {
220
- selectedComplete = false; scopeFindings.push('Release unselected, unresolved or wrong product');
238
+ if (!is(subject.id, subject.type)) { productComplete = false; selectedComplete = false; scopeFindings.push(scope.subject ? 'Subject unselected or unresolved' : 'Product unselected or unresolved'); }
239
+ if (!is(scope.release, 'RELEASE') || records.get(scope.release ?? '')?.raw.of !== subject.id) {
240
+ selectedComplete = false; scopeFindings.push(`Release unselected, unresolved or wrong ${subjectWord}`);
221
241
  }
222
- if (scope.project && (!isProject(scope.project) || !memberships(scope.project, 'project_product').includes(scope.product ?? ''))) {
223
- selectedComplete = false; scopeFindings.push('Invalid or unmodelled project/product pair');
242
+ if (scope.project && (!isProject(scope.project) || !memberships(scope.project, pairKind).includes(subject.id ?? ''))) {
243
+ selectedComplete = false; scopeFindings.push(`Invalid or unmodelled project/${subjectWord} pair`);
224
244
  }
225
245
  function query(release: string): { ids: string[]; provenance: ChainAssignment[]; complete: boolean } {
226
246
  const chain: string[] = []; let cursor: string | undefined = release; let valid = true;
227
247
  while (cursor) {
228
248
  const r: ChainRecord | undefined = records.get(cursor);
229
- if (chain.includes(cursor) || !is(cursor, 'RELEASE') || r?.raw.of !== scope.product) {
249
+ if (chain.includes(cursor) || !is(cursor, 'RELEASE') || r?.raw.of !== subject.id) {
230
250
  valid = false;
231
251
  finding(chain[chain.length - 1] ?? release, 'predecessor', 'RELEASE-SCOPE',
232
- chain.includes(cursor) ? 'Release predecessor cycle' : 'Missing, invalid or cross-product predecessor');
252
+ chain.includes(cursor) ? 'Release predecessor cycle' : `Missing, invalid or cross-${subjectWord} predecessor`);
233
253
  break;
234
254
  }
235
255
  chain.push(cursor);
@@ -251,13 +271,13 @@ export function buildRequirementChain(input: ChainInput): RequirementChainProjec
251
271
  if (scope.project && L.some(id => memberships(id, 'project_scope').length === 0 || rels.some(r => r.raw.type === 'project_scope' && r.raw.from === id && active.get(r.id) && !usable.has(r.id)))) {
252
272
  selectedComplete = false; scopeFindings.push('Unresolved project membership');
253
273
  }
254
- if (rels.some(r => ['product_scope', 'project_scope', 'required_for'].includes(String(r.raw.type)) && !envelope.get(r.id))) selectedComplete = false;
255
- if (scope.project && L.some(id => memberships(id, 'project_scope').some(project => !memberships(project, 'project_product').includes(scope.product ?? '')))) {
256
- selectedComplete = false; scopeFindings.push('Inconsistent project/product membership');
274
+ if (rels.some(r => [membershipKind, 'project_scope', 'required_for'].includes(String(r.raw.type)) && !envelope.get(r.id))) selectedComplete = false;
275
+ if (scope.project && L.some(id => memberships(id, 'project_scope').some(project => !memberships(project, pairKind).includes(subject.id ?? '')))) {
276
+ selectedComplete = false; scopeFindings.push(`Inconsistent project/${subjectWord} membership`);
257
277
  }
258
278
  const S = L.filter(id => !scope.project || memberships(id, 'project_scope').includes(scope.project));
259
279
  const elsewhere = new Set<string>();
260
- for (const release of records.values()) if (release.type === 'RELEASE' && release.raw.of === scope.product) {
280
+ for (const release of records.values()) if (release.type === 'RELEASE' && release.raw.of === subject.id) {
261
281
  const q = query(release.id); productComplete &&= q.complete; q.ids.forEach(id => elsewhere.add(id));
262
282
  }
263
283
  const unassigned = P.filter(id => !elsewhere.has(id) && !invalidAssignments.has(id));
@@ -307,7 +327,7 @@ export function buildRequirementChain(input: ChainInput): RequirementChainProjec
307
327
  const owners = new Set(ancestors);
308
328
  for (const v of verifications) if (v.raw.verifies === r.id) owners.add(v.id);
309
329
  for (const rel of rels) {
310
- const ownScope = ['product_scope', 'project_scope', 'required_for'].includes(String(rel.raw.type));
330
+ const ownScope = [membershipKind, 'project_scope', 'required_for'].includes(String(rel.raw.type));
311
331
  if (ownScope ? rel.raw.from === r.id : owners.has(String(rel.raw.from))) owners.add(rel.id);
312
332
  }
313
333
  for (const f of findings) if (owners.has(f.owner)) (affectedRequirements[f.id] ??= []).push(r.id);
@@ -326,15 +346,16 @@ export function buildRequirementChain(input: ChainInput): RequirementChainProjec
326
346
  const selectedRefs = refs.filter(f => affectedRequirements[f.id]?.some(id => S.includes(id)));
327
347
  const broken = S.filter(id => selectedRefs.some(f => affectedRequirements[f.id]?.includes(id)));
328
348
  for (const n of nodes) if (records.get(n.recordId)?.type === 'REQUIREMENT' && !S.includes(n.recordId)) {
329
- n.context.push(!active.get(n.recordId) ? 'inactive' : !P.includes(n.recordId) ? 'unresolved or other product' :
349
+ n.context.push(!active.get(n.recordId) ? 'inactive' : !P.includes(n.recordId) ? `unresolved or other ${subjectWord}` :
330
350
  !L.includes(n.recordId) ? 'other release or unassigned' : 'other project');
331
351
  }
352
+ if (scope.subject && (!is(subject.id, subject.type) || !is(scope.release, 'RELEASE') || records.get(scope.release ?? '')?.raw.of !== subject.id)) productComplete = false;
332
353
  const unassignedSet = set(unassigned, productComplete);
333
354
  const result: RequirementChainProjection = {
334
- contract: 'requirement-chain/0.2', contractRevision: '97c9d41819011ead8fe192c266cba32707eae82f', asAt, snapshotId: input.snapshotId, sourceRevision: input.sourceRevision,
355
+ contract: scope.subject ? 'requirement-chain/0.3' : 'requirement-chain/0.2', contractRevision: scope.subject ? 'a131b1862d86f34be8feb729e1cc6ebc994c3891' : '97c9d41819011ead8fe192c266cba32707eae82f', asAt, snapshotId: input.snapshotId, sourceRevision: input.sourceRevision,
335
356
  scope: structuredClone(scope), completeness: productComplete && selectedComplete ? 'complete' : 'incomplete', scopeFindings: sorted(scopeFindings),
336
357
  populations: { product: set(P, productComplete), release: set(L, selectedComplete), selected: set(S, selectedComplete),
337
- inactive: set(requirements.filter(r => envelope.get(r.id) && !active.get(r.id) && rels.some(rel => rel.raw.type === 'product_scope' && rel.raw.from === r.id && rel.raw.to === scope.product && envelope.get(rel.id))).map(r => r.id), complete),
358
+ inactive: set(requirements.filter(r => envelope.get(r.id) && !active.get(r.id) && rels.some(rel => rel.raw.type === membershipKind && rel.raw.from === r.id && rel.raw.to === subject.id && envelope.get(rel.id))).map(r => r.id), complete),
338
359
  unresolved: set(unresolved, complete) },
339
360
  assignments: { here: set(L, selectedComplete), otherReleaseOnly: set(P.filter(id => elsewhere.has(id) && !L.includes(id)), productComplete),
340
361
  unassigned: unassignedSet, invalid: set(P.filter(id => invalidAssignments.has(id)), complete), provenance: selected.provenance.sort((a, b) => a.requirement.localeCompare(b.requirement)) },
@@ -408,6 +429,7 @@ export function requirementReleaseCounts(p: RequirementChainProjection) {
408
429
  const contextUnits = { 0: 'source documents', 1: 'drivers', 2: 'needs', 7: 'definition parts', 8: 'result parts' } as const;
409
430
  for (const stage of [0, 1, 2, 7, 8] as const) add('context-' + stage, CHAIN_STAGES[stage],
410
431
  set(view.nodes.filter(n => n.stage === stage).map(n => n.id), p.populations.selected.completeness === 'complete'), contextUnits[stage]);
432
+ if (p.scope.subject) for (const count of Object.values(counts)) count.label = count.label.replace(/whole product/g, 'whole subject').replace('Product requirements', 'Subject requirements');
411
433
  return freeze(counts);
412
434
  }
413
435
 
@@ -421,7 +443,10 @@ export class RequirementChainSnapshot {
421
443
  try {
422
444
  const input = await load();
423
445
  if (generation !== this.generation) return false;
424
- this.current = buildRequirementChain(input); this.stale = false; return true;
446
+ this.current = input.scope.subject
447
+ ? buildSubjectRequirementChain({ ...input, scope: { ...input.scope, subject: input.scope.subject } })
448
+ : buildRequirementChain(input);
449
+ this.stale = false; return true;
425
450
  } catch {
426
451
  if (generation === this.generation) this.stale = true;
427
452
  return false;
@@ -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); // first definition wins
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) => {
@@ -1,4 +1,4 @@
1
- export type ProductType = 'digital_product' | 'service' | 'platform' | 'bundle';
1
+ export type ProductType = 'physical_product' | 'digital_product' | 'service' | 'platform' | 'bundle';
2
2
  export type ProductStatus = 'Draft' | 'Active' | 'Deprecated';
3
3
 
4
4
  export interface Product {
@@ -4,7 +4,7 @@ import type { ValidationError, ValidationWarning, ValidationResult } from '../va
4
4
 
5
5
  export type { ValidationError, ValidationWarning, ValidationResult };
6
6
 
7
- const VALID_TYPES = new Set<ProductType>(['digital_product', 'service', 'platform', 'bundle']);
7
+ const VALID_TYPES = new Set<ProductType>(['physical_product', 'digital_product', 'service', 'platform', 'bundle']);
8
8
  const VALID_STATUSES = new Set<ProductStatus>(['Draft', 'Active', 'Deprecated']);
9
9
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
10
10
 
@@ -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-009/011 (orphan / missing parent) are ported at warning severity even
21
- // though most standalone GOAL elements legitimately have no `parent` field —
22
- // GOAL.parent is "v0.x transitional" (ELEMENT_PRIMITIVES.md §7.2; canonical
23
- // home is a `goal_parent` REL file or the goals-tree view's inline `parent`).
24
- // `organizations/acme_corp`'s GOAL-CUST-1/GOAL-OPS-1/GOAL-EU-1 are exactly
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 a single-parent edge (GOAL.parent), matching DSM's
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 = parentOf.get(id);
150
- if (parent && known.has(parent)) {
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 — the GOAL `parent` chain must not contain a cycle. */
202
- function checkGoalParentCycle(goals: ChainElement[], findings: RepoFinding[]): void {
203
- const cyc = findParentCycle(goals);
204
- if (cyc) {
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);
@@ -1,6 +1,6 @@
1
1
  // Standalone forms from ELEMENT_PRIMITIVES and CONTRACT (Methodology 7).
2
2
  // Field payloads are opaque: only their published identity/admission contract is checked.
3
- import { isCanonicalIdOfType, typeOfId, type CanonCatalog } from '../typed-id.js';
3
+ import { isCanonicalId, isCanonicalIdOfType, typeOfId, type CanonCatalog } from '../typed-id.js';
4
4
  import type { ValidationResult } from '../validation-types.js';
5
5
 
6
6
  export const mapping = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -45,10 +45,46 @@ export function validateStandalone(notation: string, input: unknown, options: St
45
45
  };
46
46
  optionalText('description');
47
47
  if (notation === 'product') {
48
- if (!['digital_product','service','platform','bundle'].includes(d.type as string)) fail('type', 'digital_product|service|platform|bundle', d.type);
48
+ if (!['physical_product', 'digital_product','service','platform','bundle'].includes(d.type as string)) fail('type', 'physical_product|digital_product|service|platform|bundle', d.type);
49
49
  optionalText('domain'); reference('owner_role', ['ROLE']);
50
50
  if ('maturity' in d && !(Number.isInteger(d.maturity) && Number(d.maturity) >= 1 && Number(d.maturity) <= 5)) fail('maturity', 'integer 1..5', d.maturity);
51
51
  references('capabilities', ['CAPABILITY']); references('processes', ['PROCESS']); references('supporting_apps', ['APPLICATION']);
52
+ } else if (notation === 'process') {
53
+ reference('owner_role', ['ROLE']); reference('capability', ['CAPABILITY']);
54
+ references('participants', ['ROLE', 'ACTOR']);
55
+ optionalText('goal'); optionalText('result');
56
+ for (const key of ['parent', 'bpmn_file', 'maturity']) if (key in d) fail(key, 'absent inline; use canonical relations, flow or history', d[key]);
57
+ if (Array.isArray(d.participants) && new Set(d.participants).size !== d.participants.length) fail('participants', 'unique references', d.participants);
58
+ if ('flow' in d) {
59
+ if (!mapping(d.flow)) fail('flow', 'mapping', d.flow);
60
+ else {
61
+ const flow = d.flow;
62
+ const steps = Array.isArray(flow.steps) ? flow.steps : [];
63
+ const sequence = Array.isArray(flow.sequence) ? flow.sequence : [];
64
+ if (!Array.isArray(flow.steps) || !steps.length) fail('flow.steps', 'nonempty list', flow.steps);
65
+ if (!Array.isArray(flow.sequence)) fail('flow.sequence', 'list', flow.sequence);
66
+ const ids = new Set<string>();
67
+ const types = ['startEvent', 'endEvent', 'task', 'userTask', 'serviceTask', 'exclusiveGateway', 'parallelGateway'];
68
+ steps.forEach((step, i) => {
69
+ const key = `flow.steps[${i}]`;
70
+ if (!mapping(step)) { fail(key, 'mapping', step); return; }
71
+ if (!isCanonicalId(step.id) || ids.has(step.id)) fail(`${key}.id`, 'unique canonical step ID', step.id);
72
+ else ids.add(step.id);
73
+ if (!types.includes(step.type as string)) fail(`${key}.type`, types.join('|'), step.type);
74
+ if ((!['startEvent','endEvent'].includes(step.type as string) || 'name' in step) && !text(step.name)) fail(`${key}.name`, 'nonempty string', step.name);
75
+ ref(`${key}.performed_by`, step.performed_by, ['ROLE','ACTOR']);
76
+ if (!Array.isArray(d.participants) || !d.participants.includes(step.performed_by)) fail(`${key}.performed_by`, 'member of participants', step.performed_by);
77
+ if ('supported_by_application' in step) ref(`${key}.supported_by_application`, step.supported_by_application, ['APPLICATION']);
78
+ });
79
+ sequence.forEach((edge, i) => {
80
+ const key = `flow.sequence[${i}]`;
81
+ if (!mapping(edge)) { fail(key, 'mapping', edge); return; }
82
+ for (const end of ['from', 'to']) if (typeof edge[end] !== 'string' || !ids.has(edge[end] as string)) fail(`${key}.${end}`, 'ID in flow.steps', edge[end]);
83
+ if ('condition' in edge && !text(edge.condition)) fail(`${key}.condition`, 'nonempty string', edge.condition);
84
+ if ('default' in edge && typeof edge.default !== 'boolean') fail(`${key}.default`, 'boolean', edge.default);
85
+ });
86
+ }
87
+ }
52
88
  } else if (notation === 'capability') {
53
89
  if (!['domain','supporting'].includes(d.type as string)) fail('type', 'domain|supporting', d.type);
54
90
  for (const key of ['current_maturity','target_maturity','owner_role','target_date']) if (key in d) fail(key, 'attribute in history sidecar, absent inline', d[key], 'VERSIONED-004');
@@ -26,7 +26,7 @@ import type {
26
26
  } from '../activities/types.js';
27
27
  import { horizontalCubicEdgePath, bowedCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
28
28
  import { parseNodeSizePreset, resolveActionNodeSize, type NodeSizePreset } from '../node-size-presets.js';
29
- import { generateSvgEmbedCss } from '../theme/index.js';
29
+ import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
30
30
  import { emitCenteredTextSvg, layoutCenteredEntityText } from './entity-text-layout.js';
31
31
  import { escXml } from './render-util.js';
32
32
 
@@ -235,6 +235,11 @@ export interface RenderActivitiesOptions {
235
235
  * Action name in the view header.
236
236
  */
237
237
  suppressProjectNodes?: boolean;
238
+ /**
239
+ * Theme CSS embedded as `<style>` so the SVG is self-contained.
240
+ * Defaults to `'transitrix'`. VS Code live preview uses body emitters + webview CSS instead.
241
+ */
242
+ embedCssTheme?: ThemeId;
238
243
  }
239
244
 
240
245
  /**
@@ -259,6 +264,7 @@ export function renderActivitiesSvg(doc: ActivityDoc, options: RenderActivitiesO
259
264
  curvature = DEFAULT_EDGE_CURVATURE,
260
265
  entryCurvature,
261
266
  suppressProjectNodes = true,
267
+ embedCssTheme = 'transitrix',
262
268
  } = options;
263
269
  const nodeSize = resolveActionNodeSize(parseNodeSizePreset(nodeSizePreset));
264
270
  const layoutGaps: ActivitiesLayoutOptions = {
@@ -300,7 +306,7 @@ export function renderActivitiesSvg(doc: ActivityDoc, options: RenderActivitiesO
300
306
  // drop the SVG into the DOM and styling resolves without any cooperation from
301
307
  // the host stylesheet. Matches what the VS Code path produces via
302
308
  // `prepareSvgForExport`.
303
- const embedCss = generateSvgEmbedCss('transitrix') + ACTIVITIES_NETWORK_CSS;
309
+ const embedCss = generateSvgEmbedCss(embedCssTheme) + ACTIVITIES_NETWORK_CSS;
304
310
 
305
311
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
306
312
  <style>${embedCss}</style>
@@ -31,7 +31,7 @@ import type {
31
31
  ResolvedActivityCard,
32
32
  ResolvedMilestone,
33
33
  } from '../activity-card/types.js';
34
- import { generateSvgEmbedCss } from '../theme/index.js';
34
+ import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
35
35
  import { escXml } from './render-util.js';
36
36
 
37
37
  const PAD = 24;
@@ -39,6 +39,11 @@ const EMPTY_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"
39
39
 
40
40
  export interface RenderActivityCardOptions {
41
41
  title?: string;
42
+ /**
43
+ * Theme CSS embedded as `<style>` so the SVG is self-contained.
44
+ * Defaults to `'transitrix'`. VS Code live preview uses body emitters + webview CSS instead.
45
+ */
46
+ embedCssTheme?: ThemeId;
42
47
  }
43
48
 
44
49
  function truncate(text: string, maxChars: number): string {
@@ -241,7 +246,7 @@ export function renderActivityCardSvg(
241
246
  // §5.1 class convention the layout applies; keeps the import meaningful.
242
247
  void ARCHIMATE_CLASS;
243
248
 
244
- const { title = '' } = options;
249
+ const { title = '', embedCssTheme = 'transitrix' } = options;
245
250
  const resolved = resolveSingleDoc(doc);
246
251
  const layout = layoutActivityCard(resolved);
247
252
 
@@ -259,7 +264,7 @@ export function renderActivityCardSvg(
259
264
  ? `<text class="text-header" x="${PAD}" y="${PAD + 14}">${escXml(`Activity Card — ${title}`)}</text>`
260
265
  : '';
261
266
 
262
- const embedCss = generateSvgEmbedCss('transitrix');
267
+ const embedCss = generateSvgEmbedCss(embedCssTheme);
263
268
 
264
269
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
265
270
  <style>${embedCss}</style>
@@ -41,6 +41,11 @@ export interface RenderBlocksOptions {
41
41
  title?: string;
42
42
  nodeSizePreset?: NodeSizePreset;
43
43
  layoutOptions?: BlocksLayoutOptions;
44
+ /**
45
+ * Theme CSS embedded via {@link renderBlocksLayoutSvg}.
46
+ * Defaults to `'transitrix'` for self-contained host-neutral SVG.
47
+ */
48
+ embedCssTheme?: ThemeId;
44
49
  }
45
50
 
46
51
  /**
@@ -133,7 +138,7 @@ ${parts.join('\n')}
133
138
  * with the shared theme CSS embedded so the output is self-contained.
134
139
  */
135
140
  export function renderBlocksSvg(doc: BlocksFile, options: RenderBlocksOptions = {}): string {
136
- const { title = '', nodeSizePreset = 'normal', layoutOptions } = options;
141
+ const { title = '', nodeSizePreset = 'normal', layoutOptions, embedCssTheme = 'transitrix' } = options;
137
142
  const leaf = resolveBlocksLeafSize(parseNodeSizePreset(nodeSizePreset));
138
143
 
139
144
  const layout: BlocksLayout = layoutNestedBlocks(doc, {
@@ -150,7 +155,7 @@ export function renderBlocksSvg(doc: BlocksFile, options: RenderBlocksOptions =
150
155
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Nested Blocks — ${title}`)}</text>`
151
156
  : '';
152
157
 
153
- return renderBlocksLayoutSvg(layout, { title: titleSvg, embedCssTheme: 'transitrix' });
158
+ return renderBlocksLayoutSvg(layout, { title: titleSvg, embedCssTheme });
154
159
  }
155
160
 
156
161
  /**
@@ -285,11 +290,16 @@ ${body}
285
290
  export interface RenderGridOptions {
286
291
  title?: string;
287
292
  layoutOptions?: GridLayoutOptions;
293
+ /**
294
+ * Theme CSS embedded via {@link renderGridLayoutSvg}.
295
+ * Defaults to `'transitrix'` for self-contained host-neutral SVG.
296
+ */
297
+ embedCssTheme?: ThemeId;
288
298
  }
289
299
 
290
300
  /** Host-neutral grid (matrix subset) renderer (IntelliJ/UI). */
291
301
  export function renderGridSvg(doc: GridFile, options: RenderGridOptions = {}): string {
292
- const { title = '', layoutOptions } = options;
302
+ const { title = '', layoutOptions, embedCssTheme = 'transitrix' } = options;
293
303
  const layout = layoutGrid(doc, layoutOptions);
294
304
 
295
305
  if (layout.columns.length === 0 || layout.rows.length === 0) {
@@ -300,5 +310,5 @@ export function renderGridSvg(doc: GridFile, options: RenderGridOptions = {}): s
300
310
  ? `<text class="text-header" x="${PAD}" y="${PAD - 6}">${escXml(`Nested Blocks — ${title}`)}</text>`
301
311
  : '';
302
312
 
303
- return renderGridLayoutSvg(layout, { title: titleSvg, embedCssTheme: 'transitrix' });
313
+ return renderGridLayoutSvg(layout, { title: titleSvg, embedCssTheme });
304
314
  }