@transitrix/diagrams 1.6.0 → 1.7.6

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 (81) hide show
  1. package/dist/codex/__tests__/example.test.d.ts +1 -0
  2. package/dist/codex/__tests__/example.test.js +33 -0
  3. package/dist/codex/__tests__/validate.test.d.ts +1 -0
  4. package/dist/codex/__tests__/validate.test.js +68 -0
  5. package/dist/codex/index.d.ts +4 -0
  6. package/dist/codex/index.js +2 -0
  7. package/dist/codex/types.d.ts +7 -0
  8. package/dist/codex/types.js +8 -0
  9. package/dist/codex/validate.d.ts +11 -0
  10. package/dist/codex/validate.js +103 -0
  11. package/dist/compliance/__tests__/canon-catalog.test.d.ts +1 -0
  12. package/dist/compliance/__tests__/canon-catalog.test.js +60 -0
  13. package/dist/compliance/__tests__/view-resolution.test.d.ts +1 -0
  14. package/dist/compliance/__tests__/view-resolution.test.js +59 -0
  15. package/dist/compliance/canon-catalog.d.ts +19 -0
  16. package/dist/compliance/canon-catalog.js +59 -0
  17. package/dist/compliance/coverage-metric.d.ts +12 -0
  18. package/dist/compliance/coverage-metric.js +35 -0
  19. package/dist/compliance/impact.d.ts +7 -1
  20. package/dist/compliance/impact.js +53 -0
  21. package/dist/compliance/index.d.ts +6 -4
  22. package/dist/compliance/index.js +3 -2
  23. package/dist/constraint/__tests__/example.test.d.ts +1 -0
  24. package/dist/constraint/__tests__/example.test.js +14 -0
  25. package/dist/constraint/__tests__/validate.test.d.ts +1 -0
  26. package/dist/constraint/__tests__/validate.test.js +58 -0
  27. package/dist/constraint/index.d.ts +4 -0
  28. package/dist/constraint/index.js +2 -0
  29. package/dist/constraint/types.d.ts +4 -0
  30. package/dist/constraint/types.js +2 -0
  31. package/dist/constraint/validate.d.ts +7 -0
  32. package/dist/constraint/validate.js +115 -0
  33. package/dist/fgca/__tests__/parse-canonical.test.js +31 -0
  34. package/dist/fgca/parse-canonical.d.ts +1 -1
  35. package/dist/fgca/parse-canonical.js +18 -4
  36. package/dist/goals/GoalNode.d.ts +2 -1
  37. package/dist/goals/GoalNode.js +7 -2
  38. package/dist/goals/GoalTreeView.d.ts +3 -1
  39. package/dist/goals/GoalTreeView.js +31 -14
  40. package/dist/goals/__tests__/GoalTreeView.test.js +41 -0
  41. package/dist/goals/__tests__/mutations.test.js +39 -0
  42. package/dist/goals/__tests__/parse-canonical.test.js +2 -0
  43. package/dist/goals/layout.js +2 -1
  44. package/dist/goals/mutations.js +6 -6
  45. package/dist/goals/parse-canonical.d.ts +5 -0
  46. package/dist/goals/parse-canonical.js +5 -0
  47. package/dist/goals/types.d.ts +2 -0
  48. package/dist/repo-validate/types.js +2 -3
  49. package/dist/webview/__tests__/render-goals.test.js +17 -3
  50. package/dist/webview/render-goals.js +2 -1
  51. package/package.json +1 -1
  52. package/src/codex/__tests__/example.test.ts +40 -0
  53. package/src/codex/__tests__/validate.test.ts +84 -0
  54. package/src/codex/index.ts +4 -0
  55. package/src/codex/types.ts +13 -0
  56. package/src/codex/validate.ts +125 -0
  57. package/src/compliance/__tests__/canon-catalog.test.ts +68 -0
  58. package/src/compliance/__tests__/view-resolution.test.ts +80 -0
  59. package/src/compliance/canon-catalog.ts +76 -0
  60. package/src/compliance/coverage-metric.ts +50 -0
  61. package/src/compliance/impact.ts +67 -1
  62. package/src/compliance/index.ts +10 -1
  63. package/src/constraint/__tests__/example.test.ts +19 -0
  64. package/src/constraint/__tests__/validate.test.ts +68 -0
  65. package/src/constraint/index.ts +4 -0
  66. package/src/constraint/types.ts +5 -0
  67. package/src/constraint/validate.ts +131 -0
  68. package/src/fgca/__tests__/parse-canonical.test.ts +34 -0
  69. package/src/fgca/parse-canonical.ts +17 -4
  70. package/src/goals/GoalNode.tsx +8 -1
  71. package/src/goals/GoalTreeView.tsx +61 -27
  72. package/src/goals/__tests__/GoalTreeView.test.tsx +48 -0
  73. package/src/goals/__tests__/mutations.test.ts +42 -0
  74. package/src/goals/__tests__/parse-canonical.test.ts +2 -0
  75. package/src/goals/layout.ts +2 -1
  76. package/src/goals/mutations.ts +6 -6
  77. package/src/goals/parse-canonical.ts +6 -0
  78. package/src/goals/types.ts +2 -0
  79. package/src/repo-validate/types.ts +2 -3
  80. package/src/webview/__tests__/render-goals.test.ts +17 -3
  81. package/src/webview/render-goals.ts +2 -1
@@ -10,6 +10,7 @@
10
10
  import type { AssertionStatus } from '../assertion/types.js';
11
11
  import type { ComplianceCanon } from './classify.js';
12
12
  import { buildComplianceIndex } from './reverse-index.js';
13
+ import type { CanonCatalog } from '../typed-id.js';
13
14
  import type { ComplianceIndex, IndexAssertion, IndexRequirement, ObjectDetailInput, ObjectDetailDef, DeadlineStatus } from './types.js';
14
15
 
15
16
  /** Filter selecting REQUIREMENTs by codex source (jurisdiction / regime keys
@@ -75,13 +76,78 @@ export interface ImpactColumn {
75
76
  subjectType: 'product' | 'process' | 'capability';
76
77
  }
77
78
 
78
- /** A structural validation finding emitted by the matrix builder. */
79
79
  export interface ImpactFinding {
80
80
  code: string;
81
81
  severity: 'error' | 'warning';
82
82
  message: string;
83
83
  }
84
84
 
85
+ /**
86
+ * Repo-scope cross-reference checks for a parsed compliance-impact view config
87
+ * (#518 Phase C3). Surfaces unresolved subject, obligation, and regime ids before
88
+ * `buildImpactMatrix` runs.
89
+ */
90
+ export function collectImpactViewResolutionFindings(
91
+ config: ImpactViewConfig,
92
+ catalog: CanonCatalog,
93
+ complianceCanon: ComplianceCanon,
94
+ ): ImpactFinding[] {
95
+ const findings: ImpactFinding[] = [];
96
+ const codexIds = new Set(complianceCanon.codex.map((c) => c.id));
97
+
98
+ const subjectChecks: Array<{
99
+ ids: string[] | undefined;
100
+ expectedType: string;
101
+ path: string;
102
+ }> = [
103
+ { ids: config.subjects.products, expectedType: 'PRODUCT', path: 'subjects.products' },
104
+ { ids: config.subjects.processes, expectedType: 'PROCESS', path: 'subjects.processes' },
105
+ { ids: config.subjects.capabilities, expectedType: 'CAPABILITY', path: 'subjects.capabilities' },
106
+ ];
107
+
108
+ for (const { ids, expectedType, path } of subjectChecks) {
109
+ for (const id of ids ?? []) {
110
+ const t = catalog.typeOf(id);
111
+ if (t === undefined) {
112
+ findings.push({
113
+ code: 'COMPIMP-REF',
114
+ severity: 'error',
115
+ message: `${path}: "${id}" does not resolve to an admitted artefact.`,
116
+ });
117
+ } else if (t !== expectedType) {
118
+ findings.push({
119
+ code: 'COMPIMP-REF',
120
+ severity: 'error',
121
+ message: `${path}: "${id}" resolves to ${t}, not ${expectedType}.`,
122
+ });
123
+ }
124
+ }
125
+ }
126
+
127
+ for (const id of config.obligations.include ?? []) {
128
+ const t = catalog.typeOf(id);
129
+ if (t === undefined || t !== 'REQUIREMENT') {
130
+ findings.push({
131
+ code: 'COMPIMP-REF',
132
+ severity: 'error',
133
+ message: `obligations.include: "${id}" does not resolve to an admitted REQUIREMENT.`,
134
+ });
135
+ }
136
+ }
137
+
138
+ for (const id of config.obligations.filter?.derived_from_codex ?? []) {
139
+ if (!codexIds.has(id)) {
140
+ findings.push({
141
+ code: 'COMPIMP-REF',
142
+ severity: 'error',
143
+ message: `obligations.filter.derived_from_codex: "${id}" does not resolve to an admitted codex artefact.`,
144
+ });
145
+ }
146
+ }
147
+
148
+ return findings;
149
+ }
150
+
85
151
  /**
86
152
  * Grouping options for matrix columns.
87
153
  *
@@ -5,15 +5,22 @@ export { buildGapReport } from './gap-report.js';
5
5
  export type { GapReport, GapReportOptions } from './gap-report.js';
6
6
  export { emptyCanon, ingestComplianceDoc } from './classify.js';
7
7
  export type { ComplianceCanon, ComplianceProduct, ComplianceCodexDoc } from './classify.js';
8
+ export {
9
+ admitDocumentToCatalog,
10
+ buildComplianceScan,
11
+ catalogFromMap,
12
+ } from './canon-catalog.js';
13
+ export type { ComplianceScanResult, ScannedYamlDoc } from './canon-catalog.js';
8
14
  export { renderComplianceMarkdown } from './markdown.js';
9
15
  export type { ReportScope, MarkdownOptions } from './markdown.js';
10
16
  export { renderComplianceHtml, renderImpactMatrixHtml } from './html.js';
11
17
  export type { HtmlOptions, ImpactMatrixHtmlOptions } from './html.js';
12
- export { buildImpactMatrix, renderImpactMarkdown, parseImpactViewConfig, COMPLIANCE_IMPACT_DEFAULTS, extractObjectDetails, extractProcessFlowTasks, mergeStageTaskDetails, computeDeadlineStatus } from './impact.js';
18
+ export { buildImpactMatrix, renderImpactMarkdown, parseImpactViewConfig, collectImpactViewResolutionFindings, COMPLIANCE_IMPACT_DEFAULTS, extractObjectDetails, extractProcessFlowTasks, mergeStageTaskDetails, computeDeadlineStatus } from './impact.js';
13
19
  export type {
14
20
  ImpactCell,
15
21
  ImpactColumn,
16
22
  ImpactEmptyCellLabels,
23
+ ImpactFinding,
17
24
  ImpactGrouping,
18
25
  ImpactMatrix,
19
26
  ImpactObligationFilter,
@@ -31,6 +38,7 @@ export {
31
38
  export {
32
39
  parseCoverageMetricConfig,
33
40
  buildCoverageMatrix,
41
+ collectCoverageViewResolutionFindings,
34
42
  } from './coverage-metric.js';
35
43
  export type {
36
44
  CoverageMetricConfig,
@@ -40,6 +48,7 @@ export type {
40
48
  CoverageMetricThresholds,
41
49
  CoverageMatrix,
42
50
  CoverageRow,
51
+ CoverageViewFinding,
43
52
  ParseCoverageMetricResult,
44
53
  RagStatus,
45
54
  } from './coverage-metric.js';
@@ -0,0 +1,19 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+ import yaml from 'js-yaml';
6
+ import { validateConstraint } from '../validate.js';
7
+
8
+ const dir = path.resolve(
9
+ path.dirname(fileURLToPath(import.meta.url)),
10
+ '../../../../../tests/fixtures/notation-corpus/constraint',
11
+ );
12
+
13
+ describe('constraint worked example (notation-corpus)', () => {
14
+ it('validates CONSTRAINT-GDPR-RESIDENCY-1.yaml without error', () => {
15
+ const data = yaml.load(readFileSync(path.join(dir, 'CONSTRAINT-GDPR-RESIDENCY-1.yaml'), 'utf-8'));
16
+ const v = validateConstraint(data);
17
+ expect(v.valid, JSON.stringify(v.errors)).toBe(true);
18
+ });
19
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateConstraint } from '../validate.js';
3
+ import type { CanonCatalog } from '../../typed-id.js';
4
+
5
+ function valid(): Record<string, unknown> {
6
+ return {
7
+ notation: 'constraint',
8
+ id: 'CONSTRAINT-GDPR-RESIDENCY-1',
9
+ name: 'EU customer personal data must be stored within EU/EEA jurisdictions',
10
+ statement: 'Personal data of EU customers MUST NOT be persisted outside EU / EEA jurisdictions.',
11
+ status: 'active',
12
+ zone: 'canon',
13
+ admitted_at: '2026-05-29',
14
+ admitted_by: 'v.korobeinikov',
15
+ gate_checks: { uniqueness: 'pass', consistency: 'pass', completeness: 'pass' },
16
+ valid_from: '2018-05-25',
17
+ valid_to: null,
18
+ severity: 'mandatory',
19
+ owner_role: 'ROLE-DPO-1',
20
+ applies_to: ['APPLICATION-CRM-1'],
21
+ };
22
+ }
23
+
24
+ const codes = (input: unknown, opts?: Parameters<typeof validateConstraint>[1]): string[] =>
25
+ validateConstraint(input, opts).errors.map((e) => e.code);
26
+
27
+ describe('validateConstraint — positive', () => {
28
+ it('accepts a well-formed constraint', () => {
29
+ const r = validateConstraint(valid());
30
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
31
+ });
32
+ });
33
+
34
+ describe('validateConstraint — CONST-001', () => {
35
+ it('flags missing required fields and wrong notation', () => {
36
+ const r = valid();
37
+ delete r.statement;
38
+ expect(codes(r)).toContain('CONST-001');
39
+ expect(codes({ ...valid(), notation: 'requirement' })).toContain('CONST-001');
40
+ expect(codes({ ...valid(), id: 'CONSTRAINT-001' })).toContain('CONST-001');
41
+ });
42
+ });
43
+
44
+ describe('validateConstraint — CONST-002/003', () => {
45
+ it('flags invalid status and severity', () => {
46
+ expect(codes({ ...valid(), status: 'enabled' })).toContain('CONST-002');
47
+ expect(codes({ ...valid(), severity: 'critical' })).toContain('CONST-003');
48
+ });
49
+ });
50
+
51
+ describe('validateConstraint — CONST-004/005 (catalog)', () => {
52
+ it('flags unresolved applies_to and owner_role with catalog', () => {
53
+ const catalog: CanonCatalog = { typeOf: () => undefined };
54
+ expect(codes(valid(), { catalog })).toContain('CONST-004');
55
+ expect(codes(valid(), { catalog })).toContain('CONST-005');
56
+ });
57
+
58
+ it('does not flag CONST-004 without catalog for well-formed applies_to', () => {
59
+ expect(codes(valid())).not.toContain('CONST-004');
60
+ });
61
+
62
+ it('resolves applies_to when catalogue admits the target', () => {
63
+ const catalog: CanonCatalog = {
64
+ typeOf: (id) => (id === 'APPLICATION-CRM-1' ? 'APPLICATION' : id === 'ROLE-DPO-1' ? 'ROLE' : undefined),
65
+ };
66
+ expect(validateConstraint(valid(), { catalog }).valid).toBe(true);
67
+ });
68
+ });
@@ -0,0 +1,4 @@
1
+ export { CONSTRAINT_STATUSES, CONSTRAINT_SEVERITIES } from './types.js';
2
+ export type { ConstraintStatus, ConstraintSeverity } from './types.js';
3
+ export { validateConstraint } from './validate.js';
4
+ export type { ConstraintValidateOptions } from './validate.js';
@@ -0,0 +1,5 @@
1
+ export const CONSTRAINT_STATUSES = ['active', 'proposed', 'deprecated', 'retired'] as const;
2
+ export type ConstraintStatus = (typeof CONSTRAINT_STATUSES)[number];
3
+
4
+ export const CONSTRAINT_SEVERITIES = ['mandatory', 'recommended', 'advisory'] as const;
5
+ export type ConstraintSeverity = (typeof CONSTRAINT_SEVERITIES)[number];
@@ -0,0 +1,131 @@
1
+ // CONSTRAINT validator — methodology ELEMENT_PRIMITIVES.md §7.13 (sibling to
2
+ // 15-requirement.md). Implements CONST-001..005 for the compliance suite (#518 C4).
3
+
4
+ import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
5
+ import { typeOfId, isCanonicalIdOfType, type CanonCatalog } from '../typed-id.js';
6
+ import { CONSTRAINT_STATUSES, CONSTRAINT_SEVERITIES } from './types.js';
7
+
8
+ export interface ConstraintValidateOptions {
9
+ /** When provided, CONST-004/005 enforce artefact existence for cross-refs. */
10
+ catalog?: CanonCatalog;
11
+ }
12
+
13
+ const REQUIRED_FIELDS = [
14
+ 'notation',
15
+ 'name',
16
+ 'statement',
17
+ 'status',
18
+ 'zone',
19
+ 'admitted_at',
20
+ 'admitted_by',
21
+ 'gate_checks',
22
+ 'valid_from',
23
+ ] as const;
24
+
25
+ export function validateConstraint(
26
+ input: unknown,
27
+ options: ConstraintValidateOptions = {},
28
+ ): ValidationResult {
29
+ const errors: ValidationError[] = [];
30
+ const warnings: ValidationWarning[] = [];
31
+
32
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
33
+ errors.push({ code: 'CONST-001', message: 'Constraint must be a YAML mapping.' });
34
+ return { valid: false, errors, warnings };
35
+ }
36
+ const c = input as Record<string, unknown>;
37
+
38
+ if (!isCanonicalIdOfType(c.id, 'CONSTRAINT')) {
39
+ errors.push({
40
+ code: 'CONST-001',
41
+ message: `id "${String(c.id)}" must match CONSTRAINT-[<middle>-]<INTEGER>.`,
42
+ path: 'id',
43
+ });
44
+ }
45
+ if (c.notation !== 'constraint') {
46
+ errors.push({ code: 'CONST-001', message: 'notation must be the fixed value "constraint".', path: 'notation' });
47
+ }
48
+ for (const f of REQUIRED_FIELDS) {
49
+ if (f === 'notation') continue;
50
+ if (f === 'gate_checks') {
51
+ if (c.gate_checks === null || typeof c.gate_checks !== 'object') {
52
+ errors.push({ code: 'CONST-001', message: 'gate_checks is required and must be a mapping.', path: 'gate_checks' });
53
+ }
54
+ continue;
55
+ }
56
+ const v = c[f];
57
+ if (typeof v !== 'string' || v.trim() === '') {
58
+ errors.push({ code: 'CONST-001', message: `${f} is required.`, path: f });
59
+ }
60
+ }
61
+ if (c.zone !== 'canon') {
62
+ errors.push({ code: 'CONST-001', message: 'zone must be "canon" for a CONSTRAINT.', path: 'zone' });
63
+ }
64
+ if (!('valid_to' in c) || !(typeof c.valid_to === 'string' || c.valid_to === null)) {
65
+ errors.push({ code: 'CONST-001', message: 'valid_to is required (an ISO date string or null).', path: 'valid_to' });
66
+ }
67
+
68
+ const status = typeof c.status === 'string' ? c.status.trim() : '';
69
+ if (status && !(CONSTRAINT_STATUSES as readonly string[]).includes(status)) {
70
+ errors.push({
71
+ code: 'CONST-002',
72
+ message: `status must be one of ${CONSTRAINT_STATUSES.join(', ')}.`,
73
+ path: 'status',
74
+ });
75
+ }
76
+
77
+ if (c.severity !== undefined) {
78
+ const severity = typeof c.severity === 'string' ? c.severity.trim() : '';
79
+ if (!severity || !(CONSTRAINT_SEVERITIES as readonly string[]).includes(severity)) {
80
+ errors.push({
81
+ code: 'CONST-003',
82
+ message: `severity must be one of ${CONSTRAINT_SEVERITIES.join(', ')}.`,
83
+ path: 'severity',
84
+ });
85
+ }
86
+ }
87
+
88
+ if (c.applies_to !== undefined) {
89
+ if (!Array.isArray(c.applies_to)) {
90
+ errors.push({ code: 'CONST-001', message: 'applies_to must be a list of typed IDs.', path: 'applies_to' });
91
+ } else {
92
+ c.applies_to.forEach((ref, i) => {
93
+ const type = typeOfId(ref);
94
+ if (!type) {
95
+ errors.push({
96
+ code: 'CONST-004',
97
+ message: `applies_to[${i}] "${String(ref)}" is not a resolvable typed ID.`,
98
+ path: `applies_to[${i}]`,
99
+ });
100
+ return;
101
+ }
102
+ if (options.catalog && options.catalog.typeOf(ref as string) === undefined) {
103
+ errors.push({
104
+ code: 'CONST-004',
105
+ message: `applies_to[${i}] "${String(ref)}" does not resolve to an admitted artefact.`,
106
+ path: `applies_to[${i}]`,
107
+ });
108
+ }
109
+ });
110
+ }
111
+ }
112
+
113
+ if (c.owner_role !== undefined) {
114
+ const role = c.owner_role;
115
+ if (typeof role !== 'string' || !isCanonicalIdOfType(role, 'ROLE')) {
116
+ errors.push({
117
+ code: 'CONST-005',
118
+ message: `owner_role "${String(role)}" must be a typed ROLE id.`,
119
+ path: 'owner_role',
120
+ });
121
+ } else if (options.catalog && options.catalog.typeOf(role) === undefined) {
122
+ errors.push({
123
+ code: 'CONST-005',
124
+ message: `owner_role "${role}" does not resolve to an admitted artefact.`,
125
+ path: 'owner_role',
126
+ });
127
+ }
128
+ }
129
+
130
+ return { valid: errors.length === 0, errors, warnings };
131
+ }
@@ -35,6 +35,31 @@ describe('parseCanonicalFGCA', () => {
35
35
  expect(r.parsed?.activities).toHaveLength(1);
36
36
  });
37
37
 
38
+ it('accepts ACTION-* ids (methodology 1.0) alongside legacy ACTIVITY-*', () => {
39
+ const r = parseCanonicalFGCA({
40
+ ...VALID,
41
+ actions: [
42
+ { id: 'ACTION-CRM-EU-1', name: 'CRM rollout', changes: ['CHANGE-1'] },
43
+ { id: 'ACTIVITY-2', name: 'Legacy workstream', changes: ['CHANGE-1'] },
44
+ ],
45
+ });
46
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
47
+ expect(r.parsed?.activities.map((a) => a.id)).toEqual(['ACTION-CRM-EU-1', 'ACTIVITY-2']);
48
+ });
49
+
50
+ it('defaults changes to [] when view_config.layers.changes is off (DGA-in-dgca)', () => {
51
+ const r = parseCanonicalFGCA({
52
+ ...VALID,
53
+ changes: undefined,
54
+ view_config: { layers: { changes: 'off' } },
55
+ actions: [
56
+ { id: 'ACTION-DISCOVERY-1', name: 'Gap assessment', goals: ['GOAL-1'] },
57
+ ],
58
+ });
59
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
60
+ expect(r.parsed?.changes).toEqual([]);
61
+ });
62
+
38
63
  it('FGCA-001: rejects non-object input', () => {
39
64
  expect(parseCanonicalFGCA(null).errors[0].code).toBe('FGCA-001');
40
65
  expect(parseCanonicalFGCA('string').errors[0].code).toBe('FGCA-001');
@@ -198,6 +223,15 @@ describe('parseCanonicalFGA', () => {
198
223
  expect(r.parsed?.activities).toHaveLength(1);
199
224
  });
200
225
 
226
+ it('accepts ACTION-* ids in FGA mode', () => {
227
+ const r = parseCanonicalFGA({
228
+ ...VALID_FGA,
229
+ actions: [{ id: 'ACTION-GDPR-DSR-WORKFLOW-1', name: 'DSR workflow', goals: ['GOAL-1'] }],
230
+ });
231
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
232
+ expect(r.parsed?.activities[0].id).toBe('ACTION-GDPR-DSR-WORKFLOW-1');
233
+ });
234
+
201
235
  it('populates activity.goal_id from activity.goals[] (the FGA edge-driving field)', () => {
202
236
  // The "FGA nodes render, no edges" bug: activities had no resolvable
203
237
  // goal_id, so the renderer drew no goal → activity edges.
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * - Flat top-level arrays, no `fgca:` wrapper.
8
8
  * - Typed string IDs per `IDS_AND_REFERENCES.md` (`FACTOR-1`,
9
- * `GOAL-RET-1`, `CHANGE-1`, `ACTIVITY-ONBOARD-1`).
9
+ * `GOAL-RET-1`, `CHANGE-1`, `ACTION-ONBOARD-1` (legacy `ACTIVITY-*` accepted).
10
10
  * - Plural canonical-direction cross-references: `goal.factors`,
11
11
  * `change.goals`, `activity.changes`, `activity.goals`.
12
12
  * - Per-notation validation codes `FGCA-001 … FGCA-015`.
@@ -50,7 +50,8 @@ const DRIVER_ID_RE = /^DRIVER(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
50
50
  const FACTOR_OR_DRIVER_ID_RE = /^(FACTOR|DRIVER)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
51
51
  const GOAL_ID_RE = /^GOAL(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
52
52
  const CHANGE_ID_RE = /^CHANGE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
53
- const ACTIVITY_ID_RE = /^ACTIVITY(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
53
+ // ACTION is canonical since methodology 1.0; ACTIVITY accepted for legacy docs.
54
+ const ACTION_OR_ACTIVITY_ID_RE = /^(ACTION|ACTIVITY)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
54
55
  const FGCA_DOC_ID_RE = /^(FGCA|DGCA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
55
56
  const CONSTRAINT_ID_RE = /^CONSTRAINT(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
56
57
 
@@ -63,6 +64,15 @@ function asObjectArray(v: unknown): Array<Record<string, unknown>> | null {
63
64
  return v.map((el) => (el && typeof el === 'object' ? (el as Record<string, unknown>) : null) as Record<string, unknown>);
64
65
  }
65
66
 
67
+ /** DGA mode (`view_config.layers.changes: off`) may omit the changes layer. */
68
+ function isChangesLayerOff(raw: Record<string, unknown>): boolean {
69
+ const vc = raw['view_config'];
70
+ if (!vc || typeof vc !== 'object') return false;
71
+ const layers = (vc as Record<string, unknown>)['layers'];
72
+ if (!layers || typeof layers !== 'object') return false;
73
+ return (layers as Record<string, unknown>)['changes'] === 'off';
74
+ }
75
+
66
76
 
67
77
  /**
68
78
  * Validate canonical FGCA YAML and, on success, return an internal-form
@@ -114,7 +124,10 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
114
124
 
115
125
  const factorsRaw = asObjectArray(raw['factors']);
116
126
  const goalsRaw = asObjectArray(raw['goals']);
117
- const changesRaw = asObjectArray(raw['changes']);
127
+ let changesRaw = asObjectArray(raw['changes']);
128
+ if (changesRaw === null && raw['changes'] === undefined && isChangesLayerOff(raw)) {
129
+ changesRaw = [];
130
+ }
118
131
  const activitiesRaw = asObjectArray(raw['actions']);
119
132
  if (activitiesRaw === null && 'activities' in raw) {
120
133
  errors.push({ code: 'FGCA-004', message: '"activities" key is not accepted — rename to "actions"', path: 'actions' });
@@ -177,7 +190,7 @@ export function parseCanonicalFGCA(input: unknown): CanonicalFGCAResult {
177
190
  factors.forEach((el, i) => checkLayerElement(el, `factors[${i}]`, FACTOR_OR_DRIVER_ID_RE, factorIds));
178
191
  goals.forEach((el, i) => checkLayerElement(el, `goals[${i}]`, GOAL_ID_RE, goalIds));
179
192
  changes.forEach((el, i) => checkLayerElement(el, `changes[${i}]`, CHANGE_ID_RE, changeIds));
180
- activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`, ACTIVITY_ID_RE, activityIds));
193
+ activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`, ACTION_OR_ACTIVITY_ID_RE, activityIds));
181
194
 
182
195
  if (errors.length > 0) return { valid: false, errors, warnings };
183
196
 
@@ -1,6 +1,7 @@
1
1
  import React, { memo, useState } from 'react';
2
2
  import { Handle, Position } from 'reactflow';
3
3
  import type { Factor, Goal } from './types.js';
4
+ import { displayGoalId } from './parse-canonical.js';
4
5
  import type { ThemeTokens } from './theme.js';
5
6
 
6
7
  const NODE_WIDTH = 250;
@@ -23,6 +24,7 @@ export interface GoalNodeData {
23
24
  onAddChild: (parentId: number, parentLevel: number) => void;
24
25
  onDelete: (id: number) => void;
25
26
  onToggleCollapse: (id: number) => void;
27
+ onFactorClick?: (factor: Factor) => void;
26
28
  }
27
29
 
28
30
  const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
@@ -91,7 +93,7 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
91
93
  {goal.name}
92
94
  </div>
93
95
  <div style={{ fontSize: 10, color: theme.cardMetaColor, marginTop: 2 }}>
94
- ID: {goal.id} | Lvl: {goal.level}
96
+ ID: {displayGoalId(goal)} | Lvl: {goal.level}
95
97
  {goal.tag ? ` | #${goal.tag}` : ''}
96
98
  </div>
97
99
  </div>
@@ -130,6 +132,10 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
130
132
  {(openPanel === 'positive' ? positive : negative).map((f) => (
131
133
  <div
132
134
  key={f.id}
135
+ onClick={(e) => {
136
+ e.stopPropagation();
137
+ data.onFactorClick?.(f);
138
+ }}
133
139
  style={{
134
140
  background: openPanel === 'positive' ? '#f0fdf4' : '#fef2f2',
135
141
  border: `1px solid ${openPanel === 'positive' ? '#bbf7d0' : '#fecaca'}`,
@@ -138,6 +144,7 @@ const GoalNode = memo(({ data }: { data: GoalNodeData }) => {
138
144
  padding: '4px 6px',
139
145
  fontSize: 10,
140
146
  textAlign: 'left',
147
+ cursor: data.onFactorClick ? 'pointer' : 'default',
141
148
  }}
142
149
  >
143
150
  <div style={{ fontWeight: 700 }}>{f.name}</div>
@@ -12,7 +12,7 @@ import ReactFlow, {
12
12
  type NodeDragHandler,
13
13
  } from 'reactflow';
14
14
  import 'reactflow/dist/style.css';
15
- import type { Goal, GoalTree, GoalTreeLayout, LayoutOptions } from './types.js';
15
+ import type { Factor, Goal, GoalTree, GoalTreeLayout, LayoutOptions } from './types.js';
16
16
  import { layoutGoalTree } from './layout.js';
17
17
  import { reparent, addChild, deleteWithDescendants, moveToBacklog, restoreFromBacklog } from './mutations.js';
18
18
  import type { ThemeTokens } from './theme.js';
@@ -28,9 +28,11 @@ export interface GoalTreeViewProps {
28
28
  theme?: ThemeTokens;
29
29
  readOnly?: boolean;
30
30
  showBacklog?: boolean;
31
+ defaultBacklogOpen?: boolean;
31
32
  showMiniMap?: boolean;
32
33
  onChange?: (event: GoalTreeChange) => void;
33
34
  onEditRequest?: (goal: Goal) => void;
35
+ onFactorClick?: (factor: Factor) => void;
34
36
  }
35
37
 
36
38
  export type GoalTreeChange =
@@ -99,13 +101,16 @@ function GoalTreeViewInner({
99
101
  theme: themeProp,
100
102
  readOnly = false,
101
103
  showBacklog = false,
104
+ defaultBacklogOpen = true,
102
105
  showMiniMap = false,
103
106
  onChange,
104
107
  onEditRequest,
108
+ onFactorClick,
105
109
  }: GoalTreeViewProps): React.ReactElement {
106
110
  const theme = useMemo(() => resolveTheme(themeProp), [themeProp]);
107
111
  const [collapsedIds, setCollapsedIds] = useState<Set<number>>(new Set());
108
112
  const [hoveredId, setHoveredId] = useState<number | null>(null);
113
+ const [isBacklogOpen, setIsBacklogOpen] = useState(defaultBacklogOpen);
109
114
 
110
115
  const { canvas: canvasGoals, backlog: backlogGoals } = useMemo(
111
116
  () => partitionCanvasAndBacklog(tree.goals),
@@ -179,9 +184,10 @@ function GoalTreeViewInner({
179
184
  onAddChild,
180
185
  onDelete: onDeleteNode,
181
186
  onToggleCollapse,
187
+ onFactorClick,
182
188
  },
183
189
  }));
184
- }, [layout.nodes, theme, readOnly, hoveredId, collapsedIds, onAddChild, onDeleteNode, onToggleCollapse]);
190
+ }, [layout.nodes, theme, readOnly, hoveredId, collapsedIds, onAddChild, onDeleteNode, onToggleCollapse, onFactorClick]);
185
191
 
186
192
  const buildEdges = useCallback((): Edge[] => {
187
193
  return layout.edges.map((e) => ({
@@ -286,38 +292,66 @@ function GoalTreeViewInner({
286
292
  <div style={{ display: 'flex', width: '100%', height: '100%' }}>
287
293
  {showBacklogPanel && (
288
294
  <div
289
- data-goaltree-backlog-zone
295
+ {...(isBacklogOpen ? { 'data-goaltree-backlog-zone': true } : {})}
290
296
  style={{
291
- width: 220,
297
+ width: isBacklogOpen ? 220 : 28,
292
298
  flexShrink: 0,
293
299
  borderRight: '1px solid #e2e8f0',
294
- padding: 10,
295
- overflowY: 'auto',
296
300
  background: '#f8fafc',
301
+ display: 'flex',
302
+ flexDirection: 'column',
303
+ position: 'relative',
297
304
  }}
298
305
  >
299
- <div style={{ fontWeight: 700, fontSize: 12, color: '#334155', marginBottom: 8 }}>Backlog</div>
300
- {backlogGoals.map((goal) => (
301
- <div
302
- key={goal.id}
303
- draggable={!readOnly}
304
- onDragStart={(e) => onBacklogItemDragStart(e, goal.id)}
305
- onDoubleClick={() => onEditRequest?.(goal)}
306
- title="Drag onto a canvas card to set its parent"
307
- style={{
308
- padding: 8,
309
- marginBottom: 6,
310
- background: '#ffffff',
311
- border: '1px solid #cbd5e1',
312
- borderRadius: 6,
313
- fontSize: 11,
314
- cursor: readOnly ? 'default' : 'grab',
315
- }}
316
- >
317
- <div style={{ fontWeight: 600 }}>{goal.name}</div>
318
- <div style={{ color: '#64748b' }}>Level: {goal.level}</div>
306
+ <button
307
+ type="button"
308
+ aria-label={isBacklogOpen ? 'Collapse backlog' : 'Expand backlog'}
309
+ title={isBacklogOpen ? 'Collapse backlog' : 'Expand backlog'}
310
+ onClick={() => setIsBacklogOpen((v) => !v)}
311
+ style={{
312
+ alignSelf: 'flex-end',
313
+ margin: '6px 6px 0 6px',
314
+ width: 16,
315
+ height: 16,
316
+ border: '1px solid #cbd5e1',
317
+ borderRadius: 4,
318
+ background: '#ffffff',
319
+ color: '#64748b',
320
+ fontSize: 10,
321
+ lineHeight: '14px',
322
+ padding: 0,
323
+ cursor: 'pointer',
324
+ flexShrink: 0,
325
+ }}
326
+ >
327
+ {isBacklogOpen ? '◀' : '▶'}
328
+ </button>
329
+ {isBacklogOpen && (
330
+ <div style={{ padding: '4px 10px 10px', overflowY: 'auto', flex: 1 }}>
331
+ <div style={{ fontWeight: 700, fontSize: 12, color: '#334155', marginBottom: 8 }}>Backlog</div>
332
+ {backlogGoals.map((goal) => (
333
+ <div
334
+ key={goal.id}
335
+ draggable={!readOnly}
336
+ onDragStart={(e) => onBacklogItemDragStart(e, goal.id)}
337
+ onDoubleClick={() => onEditRequest?.(goal)}
338
+ title="Drag onto a canvas card to set its parent"
339
+ style={{
340
+ padding: 8,
341
+ marginBottom: 6,
342
+ background: '#ffffff',
343
+ border: '1px solid #cbd5e1',
344
+ borderRadius: 6,
345
+ fontSize: 11,
346
+ cursor: readOnly ? 'default' : 'grab',
347
+ }}
348
+ >
349
+ <div style={{ fontWeight: 600 }}>{goal.name}</div>
350
+ <div style={{ color: '#64748b' }}>Level: {goal.level}</div>
351
+ </div>
352
+ ))}
319
353
  </div>
320
- ))}
354
+ )}
321
355
  </div>
322
356
  )}
323
357