@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
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
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
+ const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../../tests/fixtures/notation-corpus/constraint');
8
+ describe('constraint worked example (notation-corpus)', () => {
9
+ it('validates CONSTRAINT-GDPR-RESIDENCY-1.yaml without error', () => {
10
+ const data = yaml.load(readFileSync(path.join(dir, 'CONSTRAINT-GDPR-RESIDENCY-1.yaml'), 'utf-8'));
11
+ const v = validateConstraint(data);
12
+ expect(v.valid, JSON.stringify(v.errors)).toBe(true);
13
+ });
14
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateConstraint } from '../validate.js';
3
+ function valid() {
4
+ return {
5
+ notation: 'constraint',
6
+ id: 'CONSTRAINT-GDPR-RESIDENCY-1',
7
+ name: 'EU customer personal data must be stored within EU/EEA jurisdictions',
8
+ statement: 'Personal data of EU customers MUST NOT be persisted outside EU / EEA jurisdictions.',
9
+ status: 'active',
10
+ zone: 'canon',
11
+ admitted_at: '2026-05-29',
12
+ admitted_by: 'v.korobeinikov',
13
+ gate_checks: { uniqueness: 'pass', consistency: 'pass', completeness: 'pass' },
14
+ valid_from: '2018-05-25',
15
+ valid_to: null,
16
+ severity: 'mandatory',
17
+ owner_role: 'ROLE-DPO-1',
18
+ applies_to: ['APPLICATION-CRM-1'],
19
+ };
20
+ }
21
+ const codes = (input, opts) => validateConstraint(input, opts).errors.map((e) => e.code);
22
+ describe('validateConstraint — positive', () => {
23
+ it('accepts a well-formed constraint', () => {
24
+ const r = validateConstraint(valid());
25
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
26
+ });
27
+ });
28
+ describe('validateConstraint — CONST-001', () => {
29
+ it('flags missing required fields and wrong notation', () => {
30
+ const r = valid();
31
+ delete r.statement;
32
+ expect(codes(r)).toContain('CONST-001');
33
+ expect(codes({ ...valid(), notation: 'requirement' })).toContain('CONST-001');
34
+ expect(codes({ ...valid(), id: 'CONSTRAINT-001' })).toContain('CONST-001');
35
+ });
36
+ });
37
+ describe('validateConstraint — CONST-002/003', () => {
38
+ it('flags invalid status and severity', () => {
39
+ expect(codes({ ...valid(), status: 'enabled' })).toContain('CONST-002');
40
+ expect(codes({ ...valid(), severity: 'critical' })).toContain('CONST-003');
41
+ });
42
+ });
43
+ describe('validateConstraint — CONST-004/005 (catalog)', () => {
44
+ it('flags unresolved applies_to and owner_role with catalog', () => {
45
+ const catalog = { typeOf: () => undefined };
46
+ expect(codes(valid(), { catalog })).toContain('CONST-004');
47
+ expect(codes(valid(), { catalog })).toContain('CONST-005');
48
+ });
49
+ it('does not flag CONST-004 without catalog for well-formed applies_to', () => {
50
+ expect(codes(valid())).not.toContain('CONST-004');
51
+ });
52
+ it('resolves applies_to when catalogue admits the target', () => {
53
+ const catalog = {
54
+ typeOf: (id) => (id === 'APPLICATION-CRM-1' ? 'APPLICATION' : id === 'ROLE-DPO-1' ? 'ROLE' : undefined),
55
+ };
56
+ expect(validateConstraint(valid(), { catalog }).valid).toBe(true);
57
+ });
58
+ });
@@ -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,2 @@
1
+ export { CONSTRAINT_STATUSES, CONSTRAINT_SEVERITIES } from './types.js';
2
+ export { validateConstraint } from './validate.js';
@@ -0,0 +1,4 @@
1
+ export declare const CONSTRAINT_STATUSES: readonly ["active", "proposed", "deprecated", "retired"];
2
+ export type ConstraintStatus = (typeof CONSTRAINT_STATUSES)[number];
3
+ export declare const CONSTRAINT_SEVERITIES: readonly ["mandatory", "recommended", "advisory"];
4
+ export type ConstraintSeverity = (typeof CONSTRAINT_SEVERITIES)[number];
@@ -0,0 +1,2 @@
1
+ export const CONSTRAINT_STATUSES = ['active', 'proposed', 'deprecated', 'retired'];
2
+ export const CONSTRAINT_SEVERITIES = ['mandatory', 'recommended', 'advisory'];
@@ -0,0 +1,7 @@
1
+ import type { ValidationResult } from '../validation-types.js';
2
+ import { type CanonCatalog } from '../typed-id.js';
3
+ export interface ConstraintValidateOptions {
4
+ /** When provided, CONST-004/005 enforce artefact existence for cross-refs. */
5
+ catalog?: CanonCatalog;
6
+ }
7
+ export declare function validateConstraint(input: unknown, options?: ConstraintValidateOptions): ValidationResult;
@@ -0,0 +1,115 @@
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
+ import { typeOfId, isCanonicalIdOfType } from '../typed-id.js';
4
+ import { CONSTRAINT_STATUSES, CONSTRAINT_SEVERITIES } from './types.js';
5
+ const REQUIRED_FIELDS = [
6
+ 'notation',
7
+ 'name',
8
+ 'statement',
9
+ 'status',
10
+ 'zone',
11
+ 'admitted_at',
12
+ 'admitted_by',
13
+ 'gate_checks',
14
+ 'valid_from',
15
+ ];
16
+ export function validateConstraint(input, options = {}) {
17
+ const errors = [];
18
+ const warnings = [];
19
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
20
+ errors.push({ code: 'CONST-001', message: 'Constraint must be a YAML mapping.' });
21
+ return { valid: false, errors, warnings };
22
+ }
23
+ const c = input;
24
+ if (!isCanonicalIdOfType(c.id, 'CONSTRAINT')) {
25
+ errors.push({
26
+ code: 'CONST-001',
27
+ message: `id "${String(c.id)}" must match CONSTRAINT-[<middle>-]<INTEGER>.`,
28
+ path: 'id',
29
+ });
30
+ }
31
+ if (c.notation !== 'constraint') {
32
+ errors.push({ code: 'CONST-001', message: 'notation must be the fixed value "constraint".', path: 'notation' });
33
+ }
34
+ for (const f of REQUIRED_FIELDS) {
35
+ if (f === 'notation')
36
+ continue;
37
+ if (f === 'gate_checks') {
38
+ if (c.gate_checks === null || typeof c.gate_checks !== 'object') {
39
+ errors.push({ code: 'CONST-001', message: 'gate_checks is required and must be a mapping.', path: 'gate_checks' });
40
+ }
41
+ continue;
42
+ }
43
+ const v = c[f];
44
+ if (typeof v !== 'string' || v.trim() === '') {
45
+ errors.push({ code: 'CONST-001', message: `${f} is required.`, path: f });
46
+ }
47
+ }
48
+ if (c.zone !== 'canon') {
49
+ errors.push({ code: 'CONST-001', message: 'zone must be "canon" for a CONSTRAINT.', path: 'zone' });
50
+ }
51
+ if (!('valid_to' in c) || !(typeof c.valid_to === 'string' || c.valid_to === null)) {
52
+ errors.push({ code: 'CONST-001', message: 'valid_to is required (an ISO date string or null).', path: 'valid_to' });
53
+ }
54
+ const status = typeof c.status === 'string' ? c.status.trim() : '';
55
+ if (status && !CONSTRAINT_STATUSES.includes(status)) {
56
+ errors.push({
57
+ code: 'CONST-002',
58
+ message: `status must be one of ${CONSTRAINT_STATUSES.join(', ')}.`,
59
+ path: 'status',
60
+ });
61
+ }
62
+ if (c.severity !== undefined) {
63
+ const severity = typeof c.severity === 'string' ? c.severity.trim() : '';
64
+ if (!severity || !CONSTRAINT_SEVERITIES.includes(severity)) {
65
+ errors.push({
66
+ code: 'CONST-003',
67
+ message: `severity must be one of ${CONSTRAINT_SEVERITIES.join(', ')}.`,
68
+ path: 'severity',
69
+ });
70
+ }
71
+ }
72
+ if (c.applies_to !== undefined) {
73
+ if (!Array.isArray(c.applies_to)) {
74
+ errors.push({ code: 'CONST-001', message: 'applies_to must be a list of typed IDs.', path: 'applies_to' });
75
+ }
76
+ else {
77
+ c.applies_to.forEach((ref, i) => {
78
+ const type = typeOfId(ref);
79
+ if (!type) {
80
+ errors.push({
81
+ code: 'CONST-004',
82
+ message: `applies_to[${i}] "${String(ref)}" is not a resolvable typed ID.`,
83
+ path: `applies_to[${i}]`,
84
+ });
85
+ return;
86
+ }
87
+ if (options.catalog && options.catalog.typeOf(ref) === undefined) {
88
+ errors.push({
89
+ code: 'CONST-004',
90
+ message: `applies_to[${i}] "${String(ref)}" does not resolve to an admitted artefact.`,
91
+ path: `applies_to[${i}]`,
92
+ });
93
+ }
94
+ });
95
+ }
96
+ }
97
+ if (c.owner_role !== undefined) {
98
+ const role = c.owner_role;
99
+ if (typeof role !== 'string' || !isCanonicalIdOfType(role, 'ROLE')) {
100
+ errors.push({
101
+ code: 'CONST-005',
102
+ message: `owner_role "${String(role)}" must be a typed ROLE id.`,
103
+ path: 'owner_role',
104
+ });
105
+ }
106
+ else if (options.catalog && options.catalog.typeOf(role) === undefined) {
107
+ errors.push({
108
+ code: 'CONST-005',
109
+ message: `owner_role "${role}" does not resolve to an admitted artefact.`,
110
+ path: 'owner_role',
111
+ });
112
+ }
113
+ }
114
+ return { valid: errors.length === 0, errors, warnings };
115
+ }
@@ -32,6 +32,29 @@ describe('parseCanonicalFGCA', () => {
32
32
  expect(r.parsed?.changes).toHaveLength(1);
33
33
  expect(r.parsed?.activities).toHaveLength(1);
34
34
  });
35
+ it('accepts ACTION-* ids (methodology 1.0) alongside legacy ACTIVITY-*', () => {
36
+ const r = parseCanonicalFGCA({
37
+ ...VALID,
38
+ actions: [
39
+ { id: 'ACTION-CRM-EU-1', name: 'CRM rollout', changes: ['CHANGE-1'] },
40
+ { id: 'ACTIVITY-2', name: 'Legacy workstream', changes: ['CHANGE-1'] },
41
+ ],
42
+ });
43
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
44
+ expect(r.parsed?.activities.map((a) => a.id)).toEqual(['ACTION-CRM-EU-1', 'ACTIVITY-2']);
45
+ });
46
+ it('defaults changes to [] when view_config.layers.changes is off (DGA-in-dgca)', () => {
47
+ const r = parseCanonicalFGCA({
48
+ ...VALID,
49
+ changes: undefined,
50
+ view_config: { layers: { changes: 'off' } },
51
+ actions: [
52
+ { id: 'ACTION-DISCOVERY-1', name: 'Gap assessment', goals: ['GOAL-1'] },
53
+ ],
54
+ });
55
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
56
+ expect(r.parsed?.changes).toEqual([]);
57
+ });
35
58
  it('FGCA-001: rejects non-object input', () => {
36
59
  expect(parseCanonicalFGCA(null).errors[0].code).toBe('FGCA-001');
37
60
  expect(parseCanonicalFGCA('string').errors[0].code).toBe('FGCA-001');
@@ -178,6 +201,14 @@ describe('parseCanonicalFGA', () => {
178
201
  expect(r.parsed?.goals).toHaveLength(1);
179
202
  expect(r.parsed?.activities).toHaveLength(1);
180
203
  });
204
+ it('accepts ACTION-* ids in FGA mode', () => {
205
+ const r = parseCanonicalFGA({
206
+ ...VALID_FGA,
207
+ actions: [{ id: 'ACTION-GDPR-DSR-WORKFLOW-1', name: 'DSR workflow', goals: ['GOAL-1'] }],
208
+ });
209
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
210
+ expect(r.parsed?.activities[0].id).toBe('ACTION-GDPR-DSR-WORKFLOW-1');
211
+ });
181
212
  it('populates activity.goal_id from activity.goals[] (the FGA edge-driving field)', () => {
182
213
  // The "FGA nodes render, no edges" bug: activities had no resolvable
183
214
  // 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`.
@@ -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`.
@@ -31,7 +31,8 @@ const DRIVER_ID_RE = /^DRIVER(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
31
31
  const FACTOR_OR_DRIVER_ID_RE = /^(FACTOR|DRIVER)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
32
32
  const GOAL_ID_RE = /^GOAL(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
33
33
  const CHANGE_ID_RE = /^CHANGE(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
34
- const ACTIVITY_ID_RE = /^ACTIVITY(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
34
+ // ACTION is canonical since methodology 1.0; ACTIVITY accepted for legacy docs.
35
+ const ACTION_OR_ACTIVITY_ID_RE = /^(ACTION|ACTIVITY)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
35
36
  const FGCA_DOC_ID_RE = /^(FGCA|DGCA)(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
36
37
  const CONSTRAINT_ID_RE = /^CONSTRAINT(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
37
38
  function isNonEmptyString(v) {
@@ -42,6 +43,16 @@ function asObjectArray(v) {
42
43
  return null;
43
44
  return v.map((el) => (el && typeof el === 'object' ? el : null));
44
45
  }
46
+ /** DGA mode (`view_config.layers.changes: off`) may omit the changes layer. */
47
+ function isChangesLayerOff(raw) {
48
+ const vc = raw['view_config'];
49
+ if (!vc || typeof vc !== 'object')
50
+ return false;
51
+ const layers = vc['layers'];
52
+ if (!layers || typeof layers !== 'object')
53
+ return false;
54
+ return layers['changes'] === 'off';
55
+ }
45
56
  /**
46
57
  * Validate canonical FGCA YAML and, on success, return an internal-form
47
58
  * `FGCADoc` ready for `buildFGCALayout`.
@@ -87,7 +98,10 @@ export function parseCanonicalFGCA(input) {
87
98
  }
88
99
  const factorsRaw = asObjectArray(raw['factors']);
89
100
  const goalsRaw = asObjectArray(raw['goals']);
90
- const changesRaw = asObjectArray(raw['changes']);
101
+ let changesRaw = asObjectArray(raw['changes']);
102
+ if (changesRaw === null && raw['changes'] === undefined && isChangesLayerOff(raw)) {
103
+ changesRaw = [];
104
+ }
91
105
  const activitiesRaw = asObjectArray(raw['actions']);
92
106
  if (activitiesRaw === null && 'activities' in raw) {
93
107
  errors.push({ code: 'FGCA-004', message: '"activities" key is not accepted — rename to "actions"', path: 'actions' });
@@ -145,7 +159,7 @@ export function parseCanonicalFGCA(input) {
145
159
  factors.forEach((el, i) => checkLayerElement(el, `factors[${i}]`, FACTOR_OR_DRIVER_ID_RE, factorIds));
146
160
  goals.forEach((el, i) => checkLayerElement(el, `goals[${i}]`, GOAL_ID_RE, goalIds));
147
161
  changes.forEach((el, i) => checkLayerElement(el, `changes[${i}]`, CHANGE_ID_RE, changeIds));
148
- activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`, ACTIVITY_ID_RE, activityIds));
162
+ activities.forEach((el, i) => checkLayerElement(el, `activities[${i}]`, ACTION_OR_ACTIVITY_ID_RE, activityIds));
149
163
  if (errors.length > 0)
150
164
  return { valid: false, errors, warnings };
151
165
  // Cross-refs.
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import type { Goal } from './types.js';
2
+ import type { Factor, Goal } from './types.js';
3
3
  import type { ThemeTokens } from './theme.js';
4
4
  export interface GoalNodeData {
5
5
  goal: Goal;
@@ -11,6 +11,7 @@ export interface GoalNodeData {
11
11
  onAddChild: (parentId: number, parentLevel: number) => void;
12
12
  onDelete: (id: number) => void;
13
13
  onToggleCollapse: (id: number) => void;
14
+ onFactorClick?: (factor: Factor) => void;
14
15
  }
15
16
  declare const GoalNode: React.MemoExoticComponent<({ data }: {
16
17
  data: GoalNodeData;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { memo, useState } from 'react';
3
3
  import { Handle, Position } from 'reactflow';
4
+ import { displayGoalId } from './parse-canonical.js';
4
5
  const NODE_WIDTH = 250;
5
6
  const NODE_HEIGHT = 80;
6
7
  function impactSign(f) {
@@ -44,7 +45,7 @@ const GoalNode = memo(({ data }) => {
44
45
  WebkitLineClamp: 2,
45
46
  WebkitBoxOrient: 'vertical',
46
47
  wordBreak: 'break-word',
47
- }, children: goal.name }), _jsxs("div", { style: { fontSize: 10, color: theme.cardMetaColor, marginTop: 2 }, children: ["ID: ", goal.id, " | Lvl: ", goal.level, goal.tag ? ` | #${goal.tag}` : ''] })] }), _jsx("div", { style: { flexShrink: 0, display: 'flex', alignItems: 'center', width: 16, justifyContent: 'flex-end' }, children: negative.length > 0 && (_jsxs("span", { role: "button", "aria-label": `${negative.length} negative factor${negative.length > 1 ? 's' : ''}`, title: `${negative.length} negative factor${negative.length > 1 ? 's' : ''}`, onClick: (e) => {
48
+ }, children: goal.name }), _jsxs("div", { style: { fontSize: 10, color: theme.cardMetaColor, marginTop: 2 }, children: ["ID: ", displayGoalId(goal), " | Lvl: ", goal.level, goal.tag ? ` | #${goal.tag}` : ''] })] }), _jsx("div", { style: { flexShrink: 0, display: 'flex', alignItems: 'center', width: 16, justifyContent: 'flex-end' }, children: negative.length > 0 && (_jsxs("span", { role: "button", "aria-label": `${negative.length} negative factor${negative.length > 1 ? 's' : ''}`, title: `${negative.length} negative factor${negative.length > 1 ? 's' : ''}`, onClick: (e) => {
48
49
  e.stopPropagation();
49
50
  setOpenPanel(openPanel === 'negative' ? null : 'negative');
50
51
  }, style: { cursor: 'pointer', color: '#dc2626', fontSize: 14, lineHeight: 1, userSelect: 'none' }, children: ["\u25BC", negative.length > 1 && _jsx("span", { style: { fontSize: 9, fontWeight: 700, marginLeft: 2 }, children: negative.length })] })) }), openPanel && (_jsx("div", { style: {
@@ -56,7 +57,10 @@ const GoalNode = memo(({ data }) => {
56
57
  flexDirection: 'column',
57
58
  gap: 4,
58
59
  ...(openPanel === 'positive' ? { bottom: 'calc(100% + 8px)' } : { top: 'calc(100% + 8px)' }),
59
- }, children: (openPanel === 'positive' ? positive : negative).map((f) => (_jsxs("div", { style: {
60
+ }, children: (openPanel === 'positive' ? positive : negative).map((f) => (_jsxs("div", { onClick: (e) => {
61
+ e.stopPropagation();
62
+ data.onFactorClick?.(f);
63
+ }, style: {
60
64
  background: openPanel === 'positive' ? '#f0fdf4' : '#fef2f2',
61
65
  border: `1px solid ${openPanel === 'positive' ? '#bbf7d0' : '#fecaca'}`,
62
66
  color: openPanel === 'positive' ? '#166534' : '#991b1b',
@@ -64,6 +68,7 @@ const GoalNode = memo(({ data }) => {
64
68
  padding: '4px 6px',
65
69
  fontSize: 10,
66
70
  textAlign: 'left',
71
+ cursor: data.onFactorClick ? 'pointer' : 'default',
67
72
  }, children: [_jsx("div", { style: { fontWeight: 700 }, children: f.name }), f.description && _jsx("div", { style: { opacity: 0.85 }, children: f.description })] }, f.id))) })), !readOnly && (_jsx("button", { type: "button", "aria-label": "Add child goal", title: "Add child goal", onClick: (e) => {
68
73
  e.stopPropagation();
69
74
  data.onAddChild(goal.id, goal.level);
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import 'reactflow/dist/style.css';
3
- import type { Goal, GoalTree, GoalTreeLayout, LayoutOptions } from './types.js';
3
+ import type { Factor, Goal, GoalTree, GoalTreeLayout, LayoutOptions } from './types.js';
4
4
  import type { ThemeTokens } from './theme.js';
5
5
  export type { ThemeTokens } from './theme.js';
6
6
  export interface GoalTreeViewProps {
@@ -10,9 +10,11 @@ export interface GoalTreeViewProps {
10
10
  theme?: ThemeTokens;
11
11
  readOnly?: boolean;
12
12
  showBacklog?: boolean;
13
+ defaultBacklogOpen?: boolean;
13
14
  showMiniMap?: boolean;
14
15
  onChange?: (event: GoalTreeChange) => void;
15
16
  onEditRequest?: (goal: Goal) => void;
17
+ onFactorClick?: (factor: Factor) => void;
16
18
  }
17
19
  export type GoalTreeChange = {
18
20
  kind: 'reparent';
@@ -61,10 +61,11 @@ function partitionCanvasAndBacklog(goals) {
61
61
  }
62
62
  return { canvas, backlog };
63
63
  }
64
- function GoalTreeViewInner({ tree, layout: layoutProp, layoutOptions, theme: themeProp, readOnly = false, showBacklog = false, showMiniMap = false, onChange, onEditRequest, }) {
64
+ function GoalTreeViewInner({ tree, layout: layoutProp, layoutOptions, theme: themeProp, readOnly = false, showBacklog = false, defaultBacklogOpen = true, showMiniMap = false, onChange, onEditRequest, onFactorClick, }) {
65
65
  const theme = useMemo(() => resolveTheme(themeProp), [themeProp]);
66
66
  const [collapsedIds, setCollapsedIds] = useState(new Set());
67
67
  const [hoveredId, setHoveredId] = useState(null);
68
+ const [isBacklogOpen, setIsBacklogOpen] = useState(defaultBacklogOpen);
68
69
  const { canvas: canvasGoals, backlog: backlogGoals } = useMemo(() => partitionCanvasAndBacklog(tree.goals), [tree.goals]);
69
70
  const layout = useMemo(() => {
70
71
  if (layoutProp)
@@ -126,9 +127,10 @@ function GoalTreeViewInner({ tree, layout: layoutProp, layoutOptions, theme: the
126
127
  onAddChild,
127
128
  onDelete: onDeleteNode,
128
129
  onToggleCollapse,
130
+ onFactorClick,
129
131
  },
130
132
  }));
131
- }, [layout.nodes, theme, readOnly, hoveredId, collapsedIds, onAddChild, onDeleteNode, onToggleCollapse]);
133
+ }, [layout.nodes, theme, readOnly, hoveredId, collapsedIds, onAddChild, onDeleteNode, onToggleCollapse, onFactorClick]);
132
134
  const buildEdges = useCallback(() => {
133
135
  return layout.edges.map((e) => ({
134
136
  id: `e${e.source}-${e.target}`,
@@ -206,22 +208,37 @@ function GoalTreeViewInner({ tree, layout: layoutProp, layoutOptions, theme: the
206
208
  onRestoreFromBacklog(goalId, Number(targetIdAttr));
207
209
  }, [readOnly, onRestoreFromBacklog]);
208
210
  const showBacklogPanel = showBacklog && backlogGoals.length > 0;
209
- return (_jsxs("div", { style: { display: 'flex', width: '100%', height: '100%' }, children: [showBacklogPanel && (_jsxs("div", { "data-goaltree-backlog-zone": true, style: {
210
- width: 220,
211
+ return (_jsxs("div", { style: { display: 'flex', width: '100%', height: '100%' }, children: [showBacklogPanel && (_jsxs("div", { ...(isBacklogOpen ? { 'data-goaltree-backlog-zone': true } : {}), style: {
212
+ width: isBacklogOpen ? 220 : 28,
211
213
  flexShrink: 0,
212
214
  borderRight: '1px solid #e2e8f0',
213
- padding: 10,
214
- overflowY: 'auto',
215
215
  background: '#f8fafc',
216
- }, children: [_jsx("div", { style: { fontWeight: 700, fontSize: 12, color: '#334155', marginBottom: 8 }, children: "Backlog" }), backlogGoals.map((goal) => (_jsxs("div", { draggable: !readOnly, onDragStart: (e) => onBacklogItemDragStart(e, goal.id), onDoubleClick: () => onEditRequest?.(goal), title: "Drag onto a canvas card to set its parent", style: {
217
- padding: 8,
218
- marginBottom: 6,
219
- background: '#ffffff',
216
+ display: 'flex',
217
+ flexDirection: 'column',
218
+ position: 'relative',
219
+ }, children: [_jsx("button", { type: "button", "aria-label": isBacklogOpen ? 'Collapse backlog' : 'Expand backlog', title: isBacklogOpen ? 'Collapse backlog' : 'Expand backlog', onClick: () => setIsBacklogOpen((v) => !v), style: {
220
+ alignSelf: 'flex-end',
221
+ margin: '6px 6px 0 6px',
222
+ width: 16,
223
+ height: 16,
220
224
  border: '1px solid #cbd5e1',
221
- borderRadius: 6,
222
- fontSize: 11,
223
- cursor: readOnly ? 'default' : 'grab',
224
- }, children: [_jsx("div", { style: { fontWeight: 600 }, children: goal.name }), _jsxs("div", { style: { color: '#64748b' }, children: ["Level: ", goal.level] })] }, goal.id)))] })), _jsx("div", { style: { flex: 1, position: 'relative' }, onDragOver: onCanvasDragOver, onDrop: onCanvasDrop, children: _jsxs(ReactFlow, { nodes: nodes, edges: edges, onNodesChange: readOnly ? undefined : onNodesChange, onEdgesChange: readOnly ? undefined : onEdgesChange, nodeTypes: nodeTypes, nodesDraggable: !readOnly, nodesConnectable: false, onNodeDrag: onNodeDrag, onNodeDragStop: onNodeDragStop, onNodeDoubleClick: onNodeDoubleClick, connectionLineType: ConnectionLineType.Bezier, fitView: true, children: [_jsx(Background, {}), _jsx(Controls, {}), showMiniMap && _jsx(MiniMap, { pannable: true, zoomable: true })] }) })] }));
225
+ borderRadius: 4,
226
+ background: '#ffffff',
227
+ color: '#64748b',
228
+ fontSize: 10,
229
+ lineHeight: '14px',
230
+ padding: 0,
231
+ cursor: 'pointer',
232
+ flexShrink: 0,
233
+ }, children: isBacklogOpen ? '◀' : '▶' }), isBacklogOpen && (_jsxs("div", { style: { padding: '4px 10px 10px', overflowY: 'auto', flex: 1 }, children: [_jsx("div", { style: { fontWeight: 700, fontSize: 12, color: '#334155', marginBottom: 8 }, children: "Backlog" }), backlogGoals.map((goal) => (_jsxs("div", { draggable: !readOnly, onDragStart: (e) => onBacklogItemDragStart(e, goal.id), onDoubleClick: () => onEditRequest?.(goal), title: "Drag onto a canvas card to set its parent", style: {
234
+ padding: 8,
235
+ marginBottom: 6,
236
+ background: '#ffffff',
237
+ border: '1px solid #cbd5e1',
238
+ borderRadius: 6,
239
+ fontSize: 11,
240
+ cursor: readOnly ? 'default' : 'grab',
241
+ }, children: [_jsx("div", { style: { fontWeight: 600 }, children: goal.name }), _jsxs("div", { style: { color: '#64748b' }, children: ["Level: ", goal.level] })] }, goal.id)))] }))] })), _jsx("div", { style: { flex: 1, position: 'relative' }, onDragOver: onCanvasDragOver, onDrop: onCanvasDrop, children: _jsxs(ReactFlow, { nodes: nodes, edges: edges, onNodesChange: readOnly ? undefined : onNodesChange, onEdgesChange: readOnly ? undefined : onEdgesChange, nodeTypes: nodeTypes, nodesDraggable: !readOnly, nodesConnectable: false, onNodeDrag: onNodeDrag, onNodeDragStop: onNodeDragStop, onNodeDoubleClick: onNodeDoubleClick, connectionLineType: ConnectionLineType.Bezier, fitView: true, children: [_jsx(Background, {}), _jsx(Controls, {}), showMiniMap && _jsx(MiniMap, { pannable: true, zoomable: true })] }) })] }));
225
242
  }
226
243
  export function GoalTreeView(props) {
227
244
  return _jsx(GoalTreeViewInner, { ...props });
@@ -281,6 +281,47 @@ describe('GoalTreeView', () => {
281
281
  expect(computeDragOutcome(deeper, 3, { kind: 'node', id: 4 })).toBeNull();
282
282
  });
283
283
  });
284
+ it('fires onFactorClick with the clicked factor when a popover item is clicked', async () => {
285
+ const onFactorClick = vi.fn();
286
+ render(_jsx(GoalTreeView, { tree: makeTree(), onFactorClick: onFactorClick }));
287
+ await waitFor(() => {
288
+ expect(screen.getByText('Cut churn')).toBeTruthy();
289
+ });
290
+ // Open the negative-factor panel on "Cut churn" (id=3, has risk factor "Support backlog")
291
+ const negativeBadge = screen.getByLabelText('1 negative factor');
292
+ negativeBadge.dispatchEvent(new MouseEvent('click', { bubbles: true }));
293
+ // The popover item for the risk factor should now be in the DOM
294
+ const factorItem = await waitFor(() => screen.getByText('Support backlog'));
295
+ factorItem.dispatchEvent(new MouseEvent('click', { bubbles: true }));
296
+ await waitFor(() => expect(onFactorClick).toHaveBeenCalledTimes(1));
297
+ expect(onFactorClick.mock.calls[0][0]).toMatchObject({ id: 10, name: 'Support backlog', impact_type: 'risk' });
298
+ });
299
+ it('backlog panel is collapsible — toggle hides and restores content', async () => {
300
+ const tree = {
301
+ goal_types: [
302
+ { name: 'Strategy', level: 0 },
303
+ { name: 'Business Goal', level: 1 },
304
+ ],
305
+ goals: [
306
+ { id: 1, name: 'Root goal', type: 'Strategy', level: 0, parent_id: 0 },
307
+ { id: 2, name: 'Orphaned goal', type: 'Business Goal', level: 1, parent_id: 999 },
308
+ ],
309
+ };
310
+ render(_jsx(GoalTreeView, { tree: tree, showBacklog: true }));
311
+ await waitFor(() => {
312
+ expect(screen.getByText('Backlog')).toBeTruthy();
313
+ });
314
+ // Collapse
315
+ screen.getByLabelText('Collapse backlog').dispatchEvent(new MouseEvent('click', { bubbles: true }));
316
+ await waitFor(() => {
317
+ expect(screen.queryByText('Backlog')).toBeNull();
318
+ });
319
+ // Expand again
320
+ screen.getByLabelText('Expand backlog').dispatchEvent(new MouseEvent('click', { bubbles: true }));
321
+ await waitFor(() => {
322
+ expect(screen.getByText('Backlog')).toBeTruthy();
323
+ });
324
+ });
284
325
  it('fires onEditRequest on double-click', async () => {
285
326
  const onEditRequest = vi.fn();
286
327
  render(_jsx(GoalTreeView, { tree: makeTree(), onEditRequest: onEditRequest }));
@@ -93,3 +93,42 @@ describe('restoreFromBacklog', () => {
93
93
  expect(r.result.goals.find(g => g.id === 3).parent_id).toBe(1);
94
94
  });
95
95
  });
96
+ describe('reparent — descendant type relabeling and level cap', () => {
97
+ const DEEP = {
98
+ goal_types: [
99
+ { name: 'Strategy', level: 0 },
100
+ { name: 'Business Goal', level: 1 },
101
+ { name: 'Project', level: 2 },
102
+ { name: 'Task', level: 3 },
103
+ ],
104
+ goals: [
105
+ { id: 1, name: 'Root', type: 'Strategy', level: 0, parent_id: 0 },
106
+ { id: 2, name: 'BG1', type: 'Business Goal', level: 1, parent_id: 1 },
107
+ { id: 3, name: 'Proj1', type: 'Project', level: 2, parent_id: 2 },
108
+ { id: 4, name: 'Task1', type: 'Task', level: 3, parent_id: 3 },
109
+ ],
110
+ };
111
+ it('relabels descendant types via getTypeForLevel when reparenting shifts levels', () => {
112
+ // Move Proj1 (id=3, level=2) directly under Root (id=1, level=0); delta = -1
113
+ // Task1 (child of Proj1, level=3): new level = 2, type should be 'Project'
114
+ const r = reparent(DEEP, 3, 1);
115
+ expect(r.ok).toBe(true);
116
+ const proj = r.result.goals.find(g => g.id === 3);
117
+ const task = r.result.goals.find(g => g.id === 4);
118
+ expect(proj.level).toBe(1);
119
+ expect(proj.type).toBe('Business Goal');
120
+ expect(task.level).toBe(2);
121
+ expect(task.type).toBe('Project');
122
+ });
123
+ it('caps descendant level at maxLevel and refuses when that violates GOALS-012', () => {
124
+ // Move Root (id=1, level=0) under Root2 (id=5, level=0); delta = +1
125
+ // BG1: 1→2, Proj1: 2→3 (maxLevel), Task1: 3→4, capped at 3
126
+ // Task1 (capped level=3) under Proj1 (level=3) violates GOALS-012 → refused
127
+ const tree = {
128
+ ...DEEP,
129
+ goals: [...DEEP.goals, { id: 5, name: 'Root2', type: 'Strategy', level: 0, parent_id: 0 }],
130
+ };
131
+ const r = reparent(tree, 1, 5);
132
+ expect(r.ok).toBe(false);
133
+ });
134
+ });
@@ -107,8 +107,10 @@ describe('parseCanonicalGoals', () => {
107
107
  it('maps canonical IDs to internal numeric ids; parent_id is the parent\'s mapped id', () => {
108
108
  const r = parseCanonicalGoals(VALID);
109
109
  expect(r.parsed?.goals[0].id).toBe(1);
110
+ expect(r.parsed?.goals[0].canonical_id).toBe('GOAL-1');
110
111
  expect(r.parsed?.goals[0].parent_id).toBe(0);
111
112
  expect(r.parsed?.goals[1].id).toBe(2);
113
+ expect(r.parsed?.goals[1].canonical_id).toBe('GOAL-2');
112
114
  expect(r.parsed?.goals[1].parent_id).toBe(1);
113
115
  });
114
116
  });
@@ -1,3 +1,4 @@
1
+ import { displayGoalId } from './parse-canonical.js';
1
2
  const DEFAULT_NODE_WIDTH = 250;
2
3
  const DEFAULT_NODE_HEIGHT = 80;
3
4
  const DEFAULT_RANK_SEP = 80;
@@ -21,7 +22,7 @@ export function selectScopedGoals(goals, scope) {
21
22
  children.set(g.parent_id, []);
22
23
  children.get(g.parent_id).push(g.id);
23
24
  }
24
- const root = goals.find(g => String(g.id) === scope.rootGoalId);
25
+ const root = goals.find(g => displayGoalId(g) === scope.rootGoalId);
25
26
  if (!root)
26
27
  return [];
27
28
  const keep = new Set();
@@ -16,12 +16,12 @@ function getTypeForLevel(tree, level) {
16
16
  const gt = tree.goal_types.find(t => t.level === level);
17
17
  return gt?.name ?? '';
18
18
  }
19
- function updateDescendantLevels(goals, id, levelDelta) {
20
- for (const g of goals) {
19
+ function updateDescendantLevels(clone, id, levelDelta, maxLevel) {
20
+ for (const g of clone.goals) {
21
21
  if (g.parent_id === id) {
22
- g.level += levelDelta;
23
- g.type = '';
24
- updateDescendantLevels(goals, g.id, levelDelta);
22
+ g.level = Math.min(g.level + levelDelta, maxLevel);
23
+ g.type = getTypeForLevel(clone, g.level);
24
+ updateDescendantLevels(clone, g.id, levelDelta, maxLevel);
25
25
  }
26
26
  }
27
27
  }
@@ -67,7 +67,7 @@ export function reparent(tree, sourceId, targetId) {
67
67
  source.level = newLevel;
68
68
  source.type = getTypeForLevel(clone, newLevel);
69
69
  if (levelDelta !== 0)
70
- updateDescendantLevels(clone.goals, sourceId, levelDelta);
70
+ updateDescendantLevels(clone, sourceId, levelDelta, maxLevel);
71
71
  const v = validateGoalTree(clone);
72
72
  if (!v.valid)
73
73
  return refusalError(v.errors[0]?.message ?? 'Validation failed after reparent');