@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
@@ -297,6 +297,54 @@ describe('GoalTreeView', () => {
297
297
  });
298
298
  });
299
299
 
300
+ it('fires onFactorClick with the clicked factor when a popover item is clicked', async () => {
301
+ const onFactorClick = vi.fn();
302
+ render(<GoalTreeView tree={makeTree()} onFactorClick={onFactorClick} />);
303
+ await waitFor(() => {
304
+ expect(screen.getByText('Cut churn')).toBeTruthy();
305
+ });
306
+
307
+ // Open the negative-factor panel on "Cut churn" (id=3, has risk factor "Support backlog")
308
+ const negativeBadge = screen.getByLabelText('1 negative factor');
309
+ negativeBadge.dispatchEvent(new MouseEvent('click', { bubbles: true }));
310
+
311
+ // The popover item for the risk factor should now be in the DOM
312
+ const factorItem = await waitFor(() => screen.getByText('Support backlog'));
313
+ factorItem.dispatchEvent(new MouseEvent('click', { bubbles: true }));
314
+
315
+ await waitFor(() => expect(onFactorClick).toHaveBeenCalledTimes(1));
316
+ expect(onFactorClick.mock.calls[0][0]).toMatchObject({ id: 10, name: 'Support backlog', impact_type: 'risk' });
317
+ });
318
+
319
+ it('backlog panel is collapsible — toggle hides and restores content', async () => {
320
+ const tree: GoalTree = {
321
+ goal_types: [
322
+ { name: 'Strategy', level: 0 },
323
+ { name: 'Business Goal', level: 1 },
324
+ ],
325
+ goals: [
326
+ { id: 1, name: 'Root goal', type: 'Strategy', level: 0, parent_id: 0 },
327
+ { id: 2, name: 'Orphaned goal', type: 'Business Goal', level: 1, parent_id: 999 },
328
+ ],
329
+ };
330
+ render(<GoalTreeView tree={tree} showBacklog />);
331
+ await waitFor(() => {
332
+ expect(screen.getByText('Backlog')).toBeTruthy();
333
+ });
334
+
335
+ // Collapse
336
+ screen.getByLabelText('Collapse backlog').dispatchEvent(new MouseEvent('click', { bubbles: true }));
337
+ await waitFor(() => {
338
+ expect(screen.queryByText('Backlog')).toBeNull();
339
+ });
340
+
341
+ // Expand again
342
+ screen.getByLabelText('Expand backlog').dispatchEvent(new MouseEvent('click', { bubbles: true }));
343
+ await waitFor(() => {
344
+ expect(screen.getByText('Backlog')).toBeTruthy();
345
+ });
346
+ });
347
+
300
348
  it('fires onEditRequest on double-click', async () => {
301
349
  const onEditRequest = vi.fn();
302
350
  render(<GoalTreeView tree={makeTree()} onEditRequest={onEditRequest} />);
@@ -109,3 +109,45 @@ describe('restoreFromBacklog', () => {
109
109
  expect(r.result!.goals.find(g => g.id === 3)!.parent_id).toBe(1);
110
110
  });
111
111
  });
112
+
113
+ describe('reparent — descendant type relabeling and level cap', () => {
114
+ const DEEP: GoalTree = {
115
+ goal_types: [
116
+ { name: 'Strategy', level: 0 },
117
+ { name: 'Business Goal', level: 1 },
118
+ { name: 'Project', level: 2 },
119
+ { name: 'Task', level: 3 },
120
+ ],
121
+ goals: [
122
+ { id: 1, name: 'Root', type: 'Strategy', level: 0, parent_id: 0 },
123
+ { id: 2, name: 'BG1', type: 'Business Goal', level: 1, parent_id: 1 },
124
+ { id: 3, name: 'Proj1', type: 'Project', level: 2, parent_id: 2 },
125
+ { id: 4, name: 'Task1', type: 'Task', level: 3, parent_id: 3 },
126
+ ],
127
+ };
128
+
129
+ it('relabels descendant types via getTypeForLevel when reparenting shifts levels', () => {
130
+ // Move Proj1 (id=3, level=2) directly under Root (id=1, level=0); delta = -1
131
+ // Task1 (child of Proj1, level=3): new level = 2, type should be 'Project'
132
+ const r = reparent(DEEP, 3, 1);
133
+ expect(r.ok).toBe(true);
134
+ const proj = r.result!.goals.find(g => g.id === 3)!;
135
+ const task = r.result!.goals.find(g => g.id === 4)!;
136
+ expect(proj.level).toBe(1);
137
+ expect(proj.type).toBe('Business Goal');
138
+ expect(task.level).toBe(2);
139
+ expect(task.type).toBe('Project');
140
+ });
141
+
142
+ it('caps descendant level at maxLevel and refuses when that violates GOALS-012', () => {
143
+ // Move Root (id=1, level=0) under Root2 (id=5, level=0); delta = +1
144
+ // BG1: 1→2, Proj1: 2→3 (maxLevel), Task1: 3→4, capped at 3
145
+ // Task1 (capped level=3) under Proj1 (level=3) violates GOALS-012 → refused
146
+ const tree: GoalTree = {
147
+ ...DEEP,
148
+ goals: [...DEEP.goals, { id: 5, name: 'Root2', type: 'Strategy', level: 0, parent_id: 0 }],
149
+ };
150
+ const r = reparent(tree, 1, 5);
151
+ expect(r.ok).toBe(false);
152
+ });
153
+ });
@@ -122,8 +122,10 @@ describe('parseCanonicalGoals', () => {
122
122
  it('maps canonical IDs to internal numeric ids; parent_id is the parent\'s mapped id', () => {
123
123
  const r = parseCanonicalGoals(VALID);
124
124
  expect(r.parsed?.goals[0].id).toBe(1);
125
+ expect(r.parsed?.goals[0].canonical_id).toBe('GOAL-1');
125
126
  expect(r.parsed?.goals[0].parent_id).toBe(0);
126
127
  expect(r.parsed?.goals[1].id).toBe(2);
128
+ expect(r.parsed?.goals[1].canonical_id).toBe('GOAL-2');
127
129
  expect(r.parsed?.goals[1].parent_id).toBe(1);
128
130
  });
129
131
  });
@@ -1,5 +1,6 @@
1
1
  import type { Goal, GoalTree, LayoutOptions, GoalTreeLayout, LaidOutNode, LaidOutEdge } from './types.js';
2
2
  import type { Scope } from '../scope.js';
3
+ import { displayGoalId } from './parse-canonical.js';
3
4
 
4
5
  const DEFAULT_NODE_WIDTH = 250;
5
6
  const DEFAULT_NODE_HEIGHT = 80;
@@ -23,7 +24,7 @@ export function selectScopedGoals(goals: Goal[], scope: Scope): Goal[] {
23
24
  if (!children.has(g.parent_id)) children.set(g.parent_id, []);
24
25
  children.get(g.parent_id)!.push(g.id);
25
26
  }
26
- const root = goals.find(g => String(g.id) === scope.rootGoalId);
27
+ const root = goals.find(g => displayGoalId(g) === scope.rootGoalId);
27
28
  if (!root) return [];
28
29
  const keep = new Set<number>();
29
30
  const stack = [root.id];
@@ -22,12 +22,12 @@ function getTypeForLevel(tree: GoalTree, level: number): string {
22
22
  return gt?.name ?? '';
23
23
  }
24
24
 
25
- function updateDescendantLevels(goals: Goal[], id: number, levelDelta: number): void {
26
- for (const g of goals) {
25
+ function updateDescendantLevels(clone: GoalTree, id: number, levelDelta: number, maxLevel: number): void {
26
+ for (const g of clone.goals) {
27
27
  if (g.parent_id === id) {
28
- g.level += levelDelta;
29
- g.type = '';
30
- updateDescendantLevels(goals, g.id, levelDelta);
28
+ g.level = Math.min(g.level + levelDelta, maxLevel);
29
+ g.type = getTypeForLevel(clone, g.level);
30
+ updateDescendantLevels(clone, g.id, levelDelta, maxLevel);
31
31
  }
32
32
  }
33
33
  }
@@ -70,7 +70,7 @@ export function reparent(tree: GoalTree, sourceId: number, targetId: number): Mu
70
70
  source.parent_id = targetId;
71
71
  source.level = newLevel;
72
72
  source.type = getTypeForLevel(clone, newLevel);
73
- if (levelDelta !== 0) updateDescendantLevels(clone.goals, sourceId, levelDelta);
73
+ if (levelDelta !== 0) updateDescendantLevels(clone, sourceId, levelDelta, maxLevel);
74
74
 
75
75
  const v = validateGoalTree(clone);
76
76
  if (!v.valid) return refusalError(v.errors[0]?.message ?? 'Validation failed after reparent');
@@ -36,6 +36,11 @@ export interface CanonicalGoalsResult extends ValidationResult {
36
36
  const GOAL_ID_RE = /^GOAL(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
37
37
  const GOALS_DOC_ID_RE = /^GOALS(-[A-Z0-9][A-Z0-9_]*)*-\d+$/;
38
38
 
39
+ /** Display id for previews — canonical `GOAL-…` when present, else internal numeric id. */
40
+ export function displayGoalId(goal: { id: number; canonical_id?: string }): string {
41
+ return goal.canonical_id ?? String(goal.id);
42
+ }
43
+
39
44
  function isNonEmptyString(v: unknown): v is string {
40
45
  return typeof v === 'string' && v.trim().length > 0;
41
46
  }
@@ -267,6 +272,7 @@ export function parseCanonicalGoals(input: unknown): CanonicalGoalsResult {
267
272
  isNonEmptyString(parent) && idToInternal.has(parent) ? idToInternal.get(parent)! : 0;
268
273
  return {
269
274
  id: idToInternal.get(o['id'] as string)!,
275
+ canonical_id: o['id'] as string,
270
276
  name: o['name'] as string,
271
277
  type: o['type'] as string,
272
278
  level: o['level'] as number,
@@ -10,6 +10,8 @@ export interface Factor {
10
10
 
11
11
  export interface Goal {
12
12
  id: number;
13
+ /** Canonical typed id from the YAML (`GOAL-…`) when parsed from canonical form. */
14
+ canonical_id?: string;
13
15
  name: string;
14
16
  type: string;
15
17
  level: number;
@@ -4,9 +4,8 @@
4
4
  // referential integrity, atomicity, ID uniqueness, policy — the checks that the
5
5
  // methodology's Python `.validators/lint.py` owns today. This converges those
6
6
  // checks onto the shared TypeScript model so there is a single validation
7
- // runtime (methodology ADR
8
- // `2026-06-11-validation-two-axis-model.md`; Studio referencing ADR
9
- // `docs/decisions/2026-06-11-validation-runtime-convergence.md`).
7
+ // runtime (methodology ADR "Validation Two-Axis Model"; Studio decision
8
+ // "Validation Runtime Convergence").
10
9
  //
11
10
  // The finding shape is deliberately minimal — `{ scope, id, message }`. The
12
11
  // richer `target` / `category` reporting taxonomy is explicitly DEFERRED per the
@@ -65,8 +65,22 @@ describe('renderGoalsSvg', () => {
65
65
  expect(svg).toContain('height="0"');
66
66
  });
67
67
 
68
- it('omits the title block when treeName is missing', () => {
69
- const svg = renderGoalsSvg(SIMPLE_TREE);
70
- expect(svg).not.toContain('Goal tree —');
68
+ it('shows canonical goal ids in node labels when present', () => {
69
+ const tree: GoalTree = {
70
+ goal_types: [{ name: 'Strategy', level: 0 }],
71
+ goals: [
72
+ {
73
+ id: 1,
74
+ canonical_id: 'GOAL-REVENUE-1',
75
+ name: 'Triple revenue',
76
+ type: 'Strategy',
77
+ level: 0,
78
+ parent_id: 0,
79
+ },
80
+ ],
81
+ };
82
+ const svg = renderGoalsSvg(tree);
83
+ expect(svg).toContain('GOAL-REVENUE-1');
84
+ expect(svg).not.toMatch(/>\s*1\s*</);
71
85
  });
72
86
  });
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { layoutGoalTree } from '../goals/layout.js';
14
14
  import type { GoalTree, GoalTreeLayout, LaidOutEdge } from '../goals/types.js';
15
+ import { displayGoalId } from '../goals/parse-canonical.js';
15
16
  import { horizontalCubicEdgePath, DEFAULT_EDGE_CURVATURE } from '../edge-path.js';
16
17
  import { generateSvgEmbedCss, type ThemeId } from '../theme/index.js';
17
18
  import { escXml } from './render-util.js';
@@ -129,7 +130,7 @@ export function renderGoalsLayoutSvg(layout: GoalTreeLayout, options: RenderGoal
129
130
  const typeY = twoLines ? y + 43 : y + 35;
130
131
  const idY = twoLines ? y + 59 : y + 55;
131
132
  const typeLabel = n.data.type ?? '';
132
- const idText = String(n.data.id);
133
+ const idText = displayGoalId(n.data);
133
134
  return `<g>
134
135
  <rect class="diagram-node level-${level}" x="${x}" y="${y}" width="${n.width}" height="${n.height}" rx="${ENTITY_NODE_RX}"/>
135
136
  <text class="text-primary" x="${x + n.width / 2}" y="${nameY1}" text-anchor="middle" dominant-baseline="central">${escXml(line1)}</text>${twoLines ? `