@transitrix/diagrams 1.14.0 → 1.16.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/compliance/catalogue.d.ts +17 -0
- package/dist/compliance/catalogue.js +68 -0
- package/dist/compliance/index.d.ts +4 -2
- package/dist/compliance/index.js +2 -1
- package/dist/compliance/requirement-chain.d.ts +13 -2
- package/dist/compliance/requirement-chain.js +60 -33
- package/dist/products/types.d.ts +1 -1
- package/dist/products/validate.js +1 -1
- package/dist/standalone/history.d.ts +3 -0
- package/dist/standalone/history.js +67 -0
- package/dist/standalone/validate.d.ts +9 -0
- package/dist/standalone/validate.js +207 -0
- package/dist/webview/render-activities.d.ts +6 -0
- package/dist/webview/render-activities.js +2 -2
- package/dist/webview/render-activity-card.d.ts +6 -0
- package/dist/webview/render-activity-card.js +2 -2
- package/dist/webview/render-blocks.d.ts +10 -0
- package/dist/webview/render-blocks.js +4 -4
- package/dist/webview/render-fgca.d.ts +6 -0
- package/dist/webview/render-fgca.js +2 -2
- package/dist/webview/render-goals.d.ts +5 -0
- package/dist/webview/render-goals.js +9 -2
- package/dist/webview/render-process-blueprint.d.ts +6 -0
- package/dist/webview/render-process-blueprint.js +2 -2
- package/dist/webview/render-products.js +1 -0
- package/dist/yaml-normalize.d.ts +3 -1
- package/dist/yaml-normalize.js +11 -3
- package/package.json +1 -1
- package/src/compliance/catalogue.ts +49 -0
- package/src/compliance/index.ts +5 -2
- package/src/compliance/requirement-chain.ts +52 -27
- package/src/products/types.ts +1 -1
- package/src/products/validate.ts +1 -1
- package/src/standalone/history.ts +45 -0
- package/src/standalone/validate.ts +129 -0
- package/src/webview/render-activities.ts +8 -2
- package/src/webview/render-activity-card.ts +8 -3
- package/src/webview/render-blocks.ts +14 -4
- package/src/webview/render-fgca.ts +8 -2
- package/src/webview/render-goals.ts +21 -2
- package/src/webview/render-process-blueprint.ts +8 -3
- package/src/webview/render-products.ts +1 -0
- 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
|
|
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
|
-
/**
|
|
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,
|
|
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(
|
|
205
|
-
if (requirements.some(r => !envelope.get(r.id)) || rels.some(r => r.raw.type ===
|
|
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',
|
|
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(
|
|
219
|
-
if (!is(scope.release, 'RELEASE') || records.get(scope.release ?? '')?.raw.of !==
|
|
220
|
-
selectedComplete = false; scopeFindings.push(
|
|
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,
|
|
223
|
-
selectedComplete = false; scopeFindings.push(
|
|
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 !==
|
|
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' :
|
|
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 => [
|
|
255
|
-
if (scope.project && L.some(id => memberships(id, 'project_scope').some(project => !memberships(project,
|
|
256
|
-
selectedComplete = false; scopeFindings.push(
|
|
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 ===
|
|
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 = [
|
|
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) ?
|
|
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 ===
|
|
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 =
|
|
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;
|
package/src/products/types.ts
CHANGED
|
@@ -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 {
|
package/src/products/validate.ts
CHANGED
|
@@ -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
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Strict raw history validation. Never filter malformed entries before checking them.
|
|
2
|
+
import type { ValidationResult } from '../validation-types.js';
|
|
3
|
+
import { mapping, isoDate, type StandaloneOptions } from './validate.js';
|
|
4
|
+
import { validateAttributeArray } from '../versioned-attribute/validate.js';
|
|
5
|
+
import { isCanonicalIdOfType } from '../typed-id.js';
|
|
6
|
+
|
|
7
|
+
export function validateHistory(input: unknown, options: StandaloneOptions = {}): ValidationResult {
|
|
8
|
+
const errors: ValidationResult['errors'] = [], warnings: ValidationResult['warnings'] = [];
|
|
9
|
+
const fail = (field: string, expected: string, actual: unknown, code = 'SCHEMA_INVALID') => errors.push({code, message: `history sidecar: ${field}: expected ${expected}; actual ${JSON.stringify(actual) ?? 'missing'}.`, path:field});
|
|
10
|
+
if (!mapping(input)) { fail('$', 'mapping', input); return {valid:false, errors, warnings}; }
|
|
11
|
+
const d = input;
|
|
12
|
+
if (typeof d.target !== 'string' || !d.target.trim()) fail('target', 'canonical target ID', d.target, 'VERSIONED-001');
|
|
13
|
+
const target = typeof d.target === 'string' ? options.documents?.get(d.target) : undefined;
|
|
14
|
+
if (options.documents && (!target || !options.catalog?.typeOf(d.target as string))) fail('target', 'admitted primitive', d.target, 'VERSIONED-001');
|
|
15
|
+
const notation = target?.notation;
|
|
16
|
+
const attributes: Record<string, string[]> = {capability: ['current_maturity','target_maturity','target_date','owner_role'], application: ['maturity','vendor','owner_role']};
|
|
17
|
+
const allowed = typeof notation === 'string' ? attributes[notation] : undefined;
|
|
18
|
+
if (!allowed) warnings.push({code:'NOTATION-SKIP-001', message: 'History target schema is unavailable; attribute coverage remains unvalidated.'});
|
|
19
|
+
if (!mapping(d.attribute_versions)) fail('attribute_versions', 'mapping', d.attribute_versions);
|
|
20
|
+
else for (const [attribute, entries] of Object.entries(d.attribute_versions)) {
|
|
21
|
+
const field = `attribute_versions.${attribute}`;
|
|
22
|
+
if (allowed && !allowed.includes(attribute)) fail(field, 'declared time_varying attribute', attribute);
|
|
23
|
+
if (!Array.isArray(entries)) { fail(field, 'array', entries); continue; }
|
|
24
|
+
const checked: {valid_from: string; value: string | number | boolean | null}[] = [];
|
|
25
|
+
for (const [i, entry] of entries.entries()) {
|
|
26
|
+
const key = `${field}[${i}]`;
|
|
27
|
+
if (!mapping(entry)) { fail(key, 'mapping', entry); continue; }
|
|
28
|
+
if (!isoDate(entry.valid_from)) fail(`${key}.valid_from`, 'ISO calendar date', entry.valid_from);
|
|
29
|
+
const value = entry.value;
|
|
30
|
+
const scalar = value === null || ['string','number','boolean'].includes(typeof value);
|
|
31
|
+
if (!('value' in entry) || !scalar) fail(`${key}.value`, 'scalar or null', value);
|
|
32
|
+
if (scalar && isoDate(entry.valid_from)) checked.push({valid_from: entry.valid_from, value: value as string | number | boolean | null});
|
|
33
|
+
if (value === null || !allowed?.includes(attribute)) continue;
|
|
34
|
+
if (['current_maturity','target_maturity','maturity'].includes(attribute) && !(Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 5)) fail(`${key}.value`, 'integer 1..5 or null', value);
|
|
35
|
+
if (attribute === 'target_date' && !isoDate(value)) fail(`${key}.value`, 'ISO calendar date or null', value);
|
|
36
|
+
if (attribute === 'vendor' && typeof value !== 'string') fail(`${key}.value`, 'string or null', value);
|
|
37
|
+
if (attribute === 'owner_role' && (!isCanonicalIdOfType(value, 'ROLE') || (options.catalog && options.catalog.typeOf(value as string) !== 'ROLE'))) fail(`${key}.value`, 'admitted ROLE reference or null', value);
|
|
38
|
+
}
|
|
39
|
+
for (const finding of validateAttributeArray(attribute, checked, typeof target?.valid_from === 'string' ? target.valid_from : undefined, target?.valid_to === null || typeof target?.valid_to === 'string' ? target.valid_to : undefined)) {
|
|
40
|
+
(finding.severity === 'error' ? errors : warnings).push({code:finding.code, message:finding.message});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const key of ['notation','id','zone','admitted_at','admitted_by','valid_from','valid_to']) if (key in d) fail(key, 'absent on headerless history sidecar', d[key]);
|
|
44
|
+
return {valid:errors.length === 0, errors, warnings};
|
|
45
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Standalone forms from ELEMENT_PRIMITIVES and CONTRACT (Methodology 7).
|
|
2
|
+
// Field payloads are opaque: only their published identity/admission contract is checked.
|
|
3
|
+
import { isCanonicalId, isCanonicalIdOfType, typeOfId, type CanonCatalog } from '../typed-id.js';
|
|
4
|
+
import type { ValidationResult } from '../validation-types.js';
|
|
5
|
+
|
|
6
|
+
export const mapping = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
7
|
+
const text = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0;
|
|
8
|
+
export const isoDate = (v: unknown): v is string => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(v)) && new Date(v).toISOString().slice(0, 10) === v;
|
|
9
|
+
const capabilityId = (v: unknown) => typeof v === 'string' && /^CAPABILITY-[VH][1-9]\d*(?:\.[1-9]\d*)*$/.test(v);
|
|
10
|
+
export interface StandaloneOptions { catalog?: CanonCatalog; documents?: ReadonlyMap<string, Record<string, unknown>> }
|
|
11
|
+
|
|
12
|
+
export function validateStandalone(notation: string, input: unknown, options: StandaloneOptions = {}): ValidationResult {
|
|
13
|
+
const errors: ValidationResult['errors'] = [], warnings: ValidationResult['warnings'] = [];
|
|
14
|
+
const fail = (field: string, expected: string, actual: unknown, code = 'SCHEMA_INVALID') => errors.push({code, message: `${notation} standalone: ${field}: expected ${expected}; actual ${JSON.stringify(actual) ?? 'missing'}.`, path: field});
|
|
15
|
+
if (!mapping(input)) { fail('$', 'mapping', input); return {valid:false, errors, warnings}; }
|
|
16
|
+
const d = input, field = ['draft', 'observation'].includes(notation);
|
|
17
|
+
const requiredText = (key: string) => { if (!text(d[key])) fail(key, 'nonempty string', d[key]); };
|
|
18
|
+
const optionalText = (key: string) => { if (key in d && !text(d[key])) fail(key, 'nonempty string', d[key]); };
|
|
19
|
+
if (d.notation !== notation && !(field && d.notation === undefined && d.type === notation.toUpperCase())) fail('notation', notation, d.notation);
|
|
20
|
+
if (!(notation === 'capability' ? capabilityId(d.id) : isCanonicalIdOfType(d.id, notation.toUpperCase()))) fail('id', `${notation.toUpperCase()} canonical ID`, d.id);
|
|
21
|
+
if (!field) requiredText('name'); else optionalText('name');
|
|
22
|
+
if (d.zone !== (field ? 'field' : 'canon')) fail('zone', field ? 'field' : 'canon', d.zone);
|
|
23
|
+
requiredText('admitted_by');
|
|
24
|
+
if (!isoDate(d.admitted_at)) fail('admitted_at', 'ISO calendar date', d.admitted_at);
|
|
25
|
+
if (!mapping(d.gate_checks)) fail('gate_checks', 'mapping', d.gate_checks);
|
|
26
|
+
else for (const key of field ? ['provenance'] : ['uniqueness', 'consistency', 'completeness']) {
|
|
27
|
+
if (!text(d.gate_checks[key])) fail(`gate_checks.${key}`, 'nonempty check outcome', d.gate_checks[key]);
|
|
28
|
+
}
|
|
29
|
+
if (!field) {
|
|
30
|
+
if (!isoDate(d.valid_from)) fail('valid_from', 'ISO calendar date', d.valid_from, 'LIFECYCLE-001');
|
|
31
|
+
if (!(d.valid_to === null || isoDate(d.valid_to))) fail('valid_to', 'ISO calendar date or null', d.valid_to, 'LIFECYCLE-002');
|
|
32
|
+
if (isoDate(d.valid_from) && isoDate(d.valid_to) && d.valid_to < d.valid_from) fail('valid_to', 'date on or after valid_from', d.valid_to, 'LIFECYCLE-003');
|
|
33
|
+
}
|
|
34
|
+
const ref = (key: string, value: unknown, types: string[]) => {
|
|
35
|
+
const type = typeOfId(value);
|
|
36
|
+
if (!type || !types.includes(type) || !(type === 'CAPABILITY' ? capabilityId(value) : isCanonicalIdOfType(value, type))) { fail(key, types.join('|') + ' canonical reference', value); return; }
|
|
37
|
+
if (options.catalog && !types.includes(options.catalog.typeOf(value as string) ?? '')) fail(key, 'reference to admitted ' + types.join('|'), value);
|
|
38
|
+
};
|
|
39
|
+
const reference = (key: string, types: string[], required = false) => { if (required || key in d) ref(key, d[key], types); };
|
|
40
|
+
const references = (key: string, types: string[], required = false) => {
|
|
41
|
+
if (!required && !(key in d)) return;
|
|
42
|
+
const values = d[key];
|
|
43
|
+
if (!Array.isArray(values) || (required && values.length === 0)) { fail(key, required ? 'nonempty list of references' : 'list of references', values); return; }
|
|
44
|
+
values.forEach((v, i) => ref(`${key}[${i}]`, v, types));
|
|
45
|
+
};
|
|
46
|
+
optionalText('description');
|
|
47
|
+
if (notation === 'product') {
|
|
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
|
+
optionalText('domain'); reference('owner_role', ['ROLE']);
|
|
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
|
+
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
|
+
}
|
|
88
|
+
} else if (notation === 'capability') {
|
|
89
|
+
if (!['domain','supporting'].includes(d.type as string)) fail('type', 'domain|supporting', d.type);
|
|
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');
|
|
91
|
+
reference('business_process', ['PROCESS']); references('applications', ['APPLICATION']);
|
|
92
|
+
} else if (notation === 'role') {
|
|
93
|
+
optionalText('responsibility_area'); reference('unit', ['ACTOR']);
|
|
94
|
+
const unit = typeof d.unit === 'string' ? options.documents?.get(d.unit) : undefined;
|
|
95
|
+
if (unit && unit.type !== 'business_unit') fail('unit', 'ACTOR of type business_unit', d.unit);
|
|
96
|
+
} else if (notation === 'assessment') {
|
|
97
|
+
requiredText('description'); reference('assesses', ['DRIVER','FACTOR'], true);
|
|
98
|
+
for (const key of ['method','source']) optionalText(key);
|
|
99
|
+
if ('observed_at' in d && !isoDate(d.observed_at)) fail('observed_at', 'ISO calendar date', d.observed_at);
|
|
100
|
+
for (const key of ['polarity','swot','type']) if (key in d) fail(key, 'absent on assessment', d[key]);
|
|
101
|
+
if ('geographic_scope' in d) {
|
|
102
|
+
const values = d.geographic_scope;
|
|
103
|
+
// Country vocabulary is checked by the shared jurisdiction set below.
|
|
104
|
+
if (!Array.isArray(values) || !values.length || values.some(v => typeof v !== 'string' || !COUNTRIES.has(v.toLowerCase())) || (values.some(v => typeof v === 'string' && v.toLowerCase() === 'global') && values.length !== 1)) fail('geographic_scope', 'nonempty ISO country/eu list, or [global]', values, 'ASSESS-001');
|
|
105
|
+
}
|
|
106
|
+
} else if (notation === 'scenario') {
|
|
107
|
+
references('pursues', ['GOAL'], true); reference('arrives_at', ['TARGET_STATE'], true); references('steps', ['ACTION','CHANGE'], true);
|
|
108
|
+
const target = typeof d.arrives_at === 'string' ? options.documents?.get(d.arrives_at) : undefined;
|
|
109
|
+
if (target?.type === 'base') fail('arrives_at', 'target plateau, not base', d.arrives_at);
|
|
110
|
+
for (const key of ['type','vision','factors_view','status','drivers','capabilities','processes','applications','products']) if (key in d) fail(key, 'absent on standalone scenario', d[key]);
|
|
111
|
+
optionalText('link');
|
|
112
|
+
} else if (field) {
|
|
113
|
+
const provenance = d.provenance;
|
|
114
|
+
if (provenance === undefined || provenance === null || (typeof provenance === 'string' && !provenance.trim()) || (mapping(provenance) && Object.keys(provenance).length === 0)) fail('provenance', 'recorded source evidence', provenance);
|
|
115
|
+
else if (!mapping(provenance)) warnings.push({code:'NOTATION-SKIP-001',message:'Field provenance form is unsupported; source evidence remains unvalidated.'});
|
|
116
|
+
else {
|
|
117
|
+
// Two supported evidence forms: captured source and versioned source import.
|
|
118
|
+
// Other forms remain visible as unsupported, not falsely validated or forbidden.
|
|
119
|
+
const captured = text(provenance.captured_by) && isoDate(provenance.captured_on) && text(provenance.setting);
|
|
120
|
+
const imported = text(provenance.source_revision) && text(provenance.original_path) && text(provenance.source_hash) && isoDate(provenance.captured_on);
|
|
121
|
+
if (!captured && !imported) warnings.push({code:'NOTATION-SKIP-001',message:'Field provenance form is unsupported: source, capture date and context cannot all be checked.'});
|
|
122
|
+
}
|
|
123
|
+
if ('source_quality' in d && !['authoritative','corroborated','single_source','unverified'].includes(d.source_quality as string)) fail('source_quality', 'authoritative|corroborated|single_source|unverified', d.source_quality);
|
|
124
|
+
}
|
|
125
|
+
return {valid: errors.length === 0, errors, warnings};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ISO 3166-1 alpha-2 vocabulary, plus the published eu/global scope tokens.
|
|
129
|
+
const COUNTRIES = new Set(('ad ae af ag ai al am ao aq ar as at au aw ax az ba bb bd be bf bg bh bi bj bl bm bn bo bq br bs bt bv bw by bz ca cc cd cf cg ch ci ck cl cm cn co cr cu cv cw cx cy cz de dj dk dm do dz ec ee eg eh er es et fi fj fk fm fo fr ga gb gd ge gf gg gh gi gl gm gn gp gq gr gs gt gu gw gy hk hm hn hr ht hu id ie il im in io iq ir is it je jm jo jp ke kg kh ki km kn kp kr kw ky kz la lb lc li lk lr ls lt lu lv ly ma mc md me mf mg mh mk ml mm mn mo mp mq mr ms mt mu mv mw mx my mz na nc ne nf ng ni nl no np nr nu nz om pa pe pf pg ph pk pl pm pn pr ps pt pw py qa re ro rs ru rw sa sb sc sd se sg sh si sj sk sl sm sn so sr ss st sv sx sy sz tc td tf tg th tj tk tl tm tn to tr tt tv tw tz ua ug um us uy uz va vc ve vg vi vn vu wf ws ye yt za zm zw eu global').split(' '));
|
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|
|
313
|
+
return renderGridLayoutSvg(layout, { title: titleSvg, embedCssTheme });
|
|
304
314
|
}
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import type { FGCADoc } from '../fgca/validate.js';
|
|
25
25
|
import { previewEdgePath, DEFAULT_EDGE_CURVATURE, type EdgeStyle } from '../edge-path.js';
|
|
26
26
|
import { parseNodeSizePreset, resolveDgcaNodeSize, type NodeSizePreset } from '../node-size-presets.js';
|
|
27
|
-
import { generateSvgEmbedCss } from '../theme/index.js';
|
|
27
|
+
import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
|
|
28
28
|
import { emitCenteredTextSvg, layoutCenteredEntityText, truncateLine } from './entity-text-layout.js';
|
|
29
29
|
import { escXml } from './render-util.js';
|
|
30
30
|
|
|
@@ -137,6 +137,11 @@ export interface RenderFgcaOptions {
|
|
|
137
137
|
scopeCaption?: boolean;
|
|
138
138
|
/** Show completion percent badges on action nodes. */
|
|
139
139
|
showCompletionPercent?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Theme CSS embedded as `<style>` so the SVG is self-contained.
|
|
142
|
+
* Defaults to `'transitrix'`. VS Code live preview uses body emitters + webview CSS instead.
|
|
143
|
+
*/
|
|
144
|
+
embedCssTheme?: ThemeId;
|
|
140
145
|
}
|
|
141
146
|
|
|
142
147
|
export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): string {
|
|
@@ -150,6 +155,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
|
|
|
150
155
|
layoutOptions,
|
|
151
156
|
scopeCaption = false,
|
|
152
157
|
showCompletionPercent = true,
|
|
158
|
+
embedCssTheme = 'transitrix',
|
|
153
159
|
} = options;
|
|
154
160
|
const hideChanges = variant === 'dga' || doc.hideChanges === true;
|
|
155
161
|
const nodeSize = resolveDgcaNodeSize(parseNodeSizePreset(nodeSizePreset));
|
|
@@ -180,7 +186,7 @@ export function renderFgcaSvg(doc: FGCADoc, options: RenderFgcaOptions = {}): st
|
|
|
180
186
|
? `<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>`
|
|
181
187
|
: '';
|
|
182
188
|
|
|
183
|
-
const embedCss = generateSvgEmbedCss(
|
|
189
|
+
const embedCss = generateSvgEmbedCss(embedCssTheme);
|
|
184
190
|
|
|
185
191
|
// Calculate final height including caption if present
|
|
186
192
|
const finalHeight = scopeCaption ? height + 24 : height;
|
|
@@ -30,6 +30,11 @@ export interface RenderGoalsOptions {
|
|
|
30
30
|
entryCurvature?: number;
|
|
31
31
|
edgeStyle?: EdgeStyle;
|
|
32
32
|
nodeSizePreset?: NodeSizePreset;
|
|
33
|
+
/**
|
|
34
|
+
* Theme CSS embedded via {@link renderGoalsLayoutSvg}.
|
|
35
|
+
* Defaults to `'transitrix'` for self-contained host-neutral SVG.
|
|
36
|
+
*/
|
|
37
|
+
embedCssTheme?: ThemeId;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
/**
|
|
@@ -38,7 +43,14 @@ export interface RenderGoalsOptions {
|
|
|
38
43
|
* with the shared theme CSS embedded so the output is self-contained.
|
|
39
44
|
*/
|
|
40
45
|
export function renderGoalsSvg(tree: GoalTree, options: RenderGoalsOptions = {}): string {
|
|
41
|
-
const {
|
|
46
|
+
const {
|
|
47
|
+
treeName = '',
|
|
48
|
+
curvature = DEFAULT_EDGE_CURVATURE,
|
|
49
|
+
entryCurvature,
|
|
50
|
+
edgeStyle,
|
|
51
|
+
nodeSizePreset = 'normal',
|
|
52
|
+
embedCssTheme = 'transitrix',
|
|
53
|
+
} = options;
|
|
42
54
|
const nodeSize = resolveGoalsNodeSize(parseNodeSizePreset(nodeSizePreset));
|
|
43
55
|
|
|
44
56
|
const layout: GoalTreeLayout = layoutGoalTree(tree, {
|
|
@@ -57,7 +69,14 @@ export function renderGoalsSvg(tree: GoalTree, options: RenderGoalsOptions = {})
|
|
|
57
69
|
: '';
|
|
58
70
|
|
|
59
71
|
// Reserve a separate header row; the theme centers text on its y coordinate.
|
|
60
|
-
return renderGoalsLayoutSvg(layout, {
|
|
72
|
+
return renderGoalsLayoutSvg(layout, {
|
|
73
|
+
curvature,
|
|
74
|
+
entryCurvature,
|
|
75
|
+
edgeStyle,
|
|
76
|
+
title,
|
|
77
|
+
topInset: title ? PAD : 0,
|
|
78
|
+
embedCssTheme,
|
|
79
|
+
});
|
|
61
80
|
}
|
|
62
81
|
|
|
63
82
|
export interface RenderGoalsLayoutOptions {
|