@kjerneverk/riotplan-catalyst 1.0.0-dev.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/.nvmrc +1 -0
- package/README.md +327 -0
- package/eslint.config.mjs +38 -0
- package/package.json +54 -0
- package/src/loader/catalyst-loader.ts +261 -0
- package/src/loader/plan-manifest.ts +228 -0
- package/src/merger/facet-merger.ts +225 -0
- package/src/riotplan-catalyst.ts +59 -0
- package/src/schema/schemas.ts +143 -0
- package/src/types.ts +140 -0
- package/tests/catalyst-loader.test.ts +243 -0
- package/tests/facet-merger.test.ts +311 -0
- package/tests/fixtures/complete-catalyst/catalyst.yml +11 -0
- package/tests/fixtures/complete-catalyst/constraints/documentation.md +7 -0
- package/tests/fixtures/complete-catalyst/constraints/testing.md +5 -0
- package/tests/fixtures/complete-catalyst/domain-knowledge/overview.md +11 -0
- package/tests/fixtures/complete-catalyst/output-templates/press-release.md +16 -0
- package/tests/fixtures/complete-catalyst/process-guidance/lifecycle.md +13 -0
- package/tests/fixtures/complete-catalyst/questions/exploration.md +10 -0
- package/tests/fixtures/complete-catalyst/questions/shaping.md +5 -0
- package/tests/fixtures/complete-catalyst/validation-rules/checklist.md +11 -0
- package/tests/fixtures/invalid-catalyst/questions/some-questions.md +3 -0
- package/tests/fixtures/partial-catalyst/catalyst.yml +7 -0
- package/tests/fixtures/partial-catalyst/constraints/general.md +4 -0
- package/tests/fixtures/partial-catalyst/questions/basics.md +4 -0
- package/tests/plan-manifest.test.ts +315 -0
- package/tests/schema.test.ts +308 -0
- package/tests/setup.ts +1 -0
- package/tsconfig.json +22 -0
- package/vite.config.ts +43 -0
- package/vitest.config.ts +28 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TypeScript interfaces for the catalyst system
|
|
3
|
+
* @packageDocumentation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { FacetType } from '@/schema/schemas';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Content loaded from a single facet file
|
|
10
|
+
*/
|
|
11
|
+
export interface FacetContent {
|
|
12
|
+
/** Filename without path (e.g., "testing.md") */
|
|
13
|
+
filename: string;
|
|
14
|
+
/** Full content of the file */
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Container for all loaded facet content from a catalyst
|
|
20
|
+
* Each facet type maps to an array of file contents
|
|
21
|
+
*/
|
|
22
|
+
export interface CatalystFacets {
|
|
23
|
+
/** Guiding questions for idea exploration and shaping */
|
|
24
|
+
questions?: FacetContent[];
|
|
25
|
+
/** Constraints and rules the plan must satisfy */
|
|
26
|
+
constraints?: FacetContent[];
|
|
27
|
+
/** Templates for expected deliverables (press release, 6-pager, etc.) */
|
|
28
|
+
outputTemplates?: FacetContent[];
|
|
29
|
+
/** Context about the domain, organization, or technology */
|
|
30
|
+
domainKnowledge?: FacetContent[];
|
|
31
|
+
/** Guidance on how to run the planning process */
|
|
32
|
+
processGuidance?: FacetContent[];
|
|
33
|
+
/** Post-creation validation checks */
|
|
34
|
+
validationRules?: FacetContent[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parsed catalyst.yml manifest
|
|
39
|
+
*/
|
|
40
|
+
export interface CatalystManifest {
|
|
41
|
+
/** Catalyst identifier (NPM package name) */
|
|
42
|
+
id: string;
|
|
43
|
+
/** Human-readable name */
|
|
44
|
+
name: string;
|
|
45
|
+
/** Description of what this catalyst provides */
|
|
46
|
+
description: string;
|
|
47
|
+
/** Semver version */
|
|
48
|
+
version: string;
|
|
49
|
+
/** Declaration of which facets this catalyst provides */
|
|
50
|
+
facets?: {
|
|
51
|
+
questions?: boolean;
|
|
52
|
+
constraints?: boolean;
|
|
53
|
+
outputTemplates?: boolean;
|
|
54
|
+
domainKnowledge?: boolean;
|
|
55
|
+
processGuidance?: boolean;
|
|
56
|
+
validationRules?: boolean;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A fully loaded catalyst with manifest and all facet content
|
|
62
|
+
*/
|
|
63
|
+
export interface Catalyst {
|
|
64
|
+
/** Parsed manifest from catalyst.yml */
|
|
65
|
+
manifest: CatalystManifest;
|
|
66
|
+
/** Loaded content for each facet type */
|
|
67
|
+
facets: CatalystFacets;
|
|
68
|
+
/** Absolute path to the catalyst directory */
|
|
69
|
+
directoryPath: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Result of loading a catalyst - either success or error
|
|
74
|
+
*/
|
|
75
|
+
export interface CatalystLoadResult {
|
|
76
|
+
success: boolean;
|
|
77
|
+
catalyst?: Catalyst;
|
|
78
|
+
error?: string;
|
|
79
|
+
warnings?: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Options for loading a catalyst
|
|
84
|
+
*/
|
|
85
|
+
export interface CatalystLoadOptions {
|
|
86
|
+
/** Whether to validate the manifest strictly */
|
|
87
|
+
strict?: boolean;
|
|
88
|
+
/** Whether to warn about declared but missing facets */
|
|
89
|
+
warnOnMissingFacets?: boolean;
|
|
90
|
+
/** Whether to warn about present but undeclared facets */
|
|
91
|
+
warnOnUndeclaredFacets?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Mapping from facet type to directory name on disk
|
|
96
|
+
*/
|
|
97
|
+
export type FacetDirectoryMap = Record<FacetType, string>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Plan manifest stored in plan.yaml
|
|
101
|
+
*/
|
|
102
|
+
export interface PlanManifest {
|
|
103
|
+
/** Plan identifier */
|
|
104
|
+
id: string;
|
|
105
|
+
/** Human-readable title */
|
|
106
|
+
title: string;
|
|
107
|
+
/** Ordered list of catalyst IDs */
|
|
108
|
+
catalysts?: string[];
|
|
109
|
+
/** ISO timestamp of creation */
|
|
110
|
+
created?: string;
|
|
111
|
+
/** Extensible metadata */
|
|
112
|
+
metadata?: Record<string, string>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Content with source attribution (used in merged catalysts)
|
|
117
|
+
*/
|
|
118
|
+
export interface AttributedContent {
|
|
119
|
+
/** The actual content */
|
|
120
|
+
content: string;
|
|
121
|
+
/** ID of the source catalyst */
|
|
122
|
+
sourceId: string;
|
|
123
|
+
/** Original filename (for reference) */
|
|
124
|
+
filename?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Merged catalysts with source tracking
|
|
129
|
+
*/
|
|
130
|
+
export interface MergedCatalyst {
|
|
131
|
+
/** Ordered list of catalyst IDs that were merged */
|
|
132
|
+
catalystIds: string[];
|
|
133
|
+
/** Merged facets with source attribution */
|
|
134
|
+
facets: Record<string, AttributedContent[] | undefined>;
|
|
135
|
+
/** Metadata about each catalyst's contribution */
|
|
136
|
+
contributions: Map<string, {
|
|
137
|
+
facetTypes: string[];
|
|
138
|
+
contentCount: number;
|
|
139
|
+
}>;
|
|
140
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
loadCatalyst,
|
|
5
|
+
loadCatalystSafe,
|
|
6
|
+
resolveCatalysts,
|
|
7
|
+
} from '@/loader/catalyst-loader';
|
|
8
|
+
|
|
9
|
+
const FIXTURES_DIR = join(__dirname, 'fixtures');
|
|
10
|
+
const COMPLETE_CATALYST = join(FIXTURES_DIR, 'complete-catalyst');
|
|
11
|
+
const PARTIAL_CATALYST = join(FIXTURES_DIR, 'partial-catalyst');
|
|
12
|
+
const INVALID_CATALYST = join(FIXTURES_DIR, 'invalid-catalyst');
|
|
13
|
+
|
|
14
|
+
describe('loadCatalyst', () => {
|
|
15
|
+
describe('loading complete catalyst', () => {
|
|
16
|
+
it('loads a catalyst with all facets', async () => {
|
|
17
|
+
const catalyst = await loadCatalyst(COMPLETE_CATALYST);
|
|
18
|
+
|
|
19
|
+
expect(catalyst.manifest.id).toBe('@test/complete-catalyst');
|
|
20
|
+
expect(catalyst.manifest.name).toBe('Complete Test Catalyst');
|
|
21
|
+
expect(catalyst.manifest.version).toBe('1.0.0');
|
|
22
|
+
expect(catalyst.directoryPath).toBe(COMPLETE_CATALYST);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('loads all six facet types', async () => {
|
|
26
|
+
const catalyst = await loadCatalyst(COMPLETE_CATALYST);
|
|
27
|
+
|
|
28
|
+
expect(catalyst.facets.questions).toBeDefined();
|
|
29
|
+
expect(catalyst.facets.constraints).toBeDefined();
|
|
30
|
+
expect(catalyst.facets.outputTemplates).toBeDefined();
|
|
31
|
+
expect(catalyst.facets.domainKnowledge).toBeDefined();
|
|
32
|
+
expect(catalyst.facets.processGuidance).toBeDefined();
|
|
33
|
+
expect(catalyst.facets.validationRules).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('loads multiple files per facet', async () => {
|
|
37
|
+
const catalyst = await loadCatalyst(COMPLETE_CATALYST);
|
|
38
|
+
|
|
39
|
+
// Questions has 2 files: exploration.md and shaping.md
|
|
40
|
+
expect(catalyst.facets.questions).toHaveLength(2);
|
|
41
|
+
expect(catalyst.facets.questions?.map(f => f.filename).sort()).toEqual([
|
|
42
|
+
'exploration.md',
|
|
43
|
+
'shaping.md',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// Constraints has 2 files
|
|
47
|
+
expect(catalyst.facets.constraints).toHaveLength(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('loads file content correctly', async () => {
|
|
51
|
+
const catalyst = await loadCatalyst(COMPLETE_CATALYST);
|
|
52
|
+
|
|
53
|
+
const exploration = catalyst.facets.questions?.find(
|
|
54
|
+
f => f.filename === 'exploration.md'
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(exploration).toBeDefined();
|
|
58
|
+
expect(exploration?.content).toContain('Exploration Questions');
|
|
59
|
+
expect(exploration?.content).toContain('What problem are you trying to solve?');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('loading partial catalyst', () => {
|
|
64
|
+
it('loads a catalyst with only some facets', async () => {
|
|
65
|
+
const catalyst = await loadCatalyst(PARTIAL_CATALYST);
|
|
66
|
+
|
|
67
|
+
expect(catalyst.manifest.id).toBe('@test/partial-catalyst');
|
|
68
|
+
expect(catalyst.manifest.version).toBe('1.0.0-dev.0');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('loads only declared facets', async () => {
|
|
72
|
+
const catalyst = await loadCatalyst(PARTIAL_CATALYST);
|
|
73
|
+
|
|
74
|
+
// Only questions and constraints are present
|
|
75
|
+
expect(catalyst.facets.questions).toBeDefined();
|
|
76
|
+
expect(catalyst.facets.constraints).toBeDefined();
|
|
77
|
+
|
|
78
|
+
// Other facets should be undefined
|
|
79
|
+
expect(catalyst.facets.outputTemplates).toBeUndefined();
|
|
80
|
+
expect(catalyst.facets.domainKnowledge).toBeUndefined();
|
|
81
|
+
expect(catalyst.facets.processGuidance).toBeUndefined();
|
|
82
|
+
expect(catalyst.facets.validationRules).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('handles missing facets gracefully', async () => {
|
|
86
|
+
const catalyst = await loadCatalyst(PARTIAL_CATALYST);
|
|
87
|
+
|
|
88
|
+
// Should not throw, just have undefined facets
|
|
89
|
+
expect(catalyst).toBeDefined();
|
|
90
|
+
expect(catalyst.facets).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('error handling', () => {
|
|
95
|
+
it('throws error for missing manifest', async () => {
|
|
96
|
+
await expect(loadCatalyst(INVALID_CATALYST)).rejects.toThrow(
|
|
97
|
+
'Catalyst manifest not found'
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('throws error for non-existent directory', async () => {
|
|
102
|
+
await expect(loadCatalyst('/nonexistent/path')).rejects.toThrow(
|
|
103
|
+
'Catalyst directory not found'
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('throws error for invalid manifest', async () => {
|
|
108
|
+
// Create a test with invalid YAML by using a temp directory
|
|
109
|
+
// For now, we'll skip this test as it requires temp file creation
|
|
110
|
+
// This would be tested in integration tests
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('path resolution', () => {
|
|
115
|
+
it('resolves relative paths', async () => {
|
|
116
|
+
const catalyst = await loadCatalyst('./tests/fixtures/complete-catalyst');
|
|
117
|
+
expect(catalyst.manifest.id).toBe('@test/complete-catalyst');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('resolves absolute paths', async () => {
|
|
121
|
+
const catalyst = await loadCatalyst(COMPLETE_CATALYST);
|
|
122
|
+
expect(catalyst.manifest.id).toBe('@test/complete-catalyst');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('stores absolute path in catalyst', async () => {
|
|
126
|
+
const catalyst = await loadCatalyst('./tests/fixtures/complete-catalyst');
|
|
127
|
+
expect(catalyst.directoryPath).toContain('tests/fixtures/complete-catalyst');
|
|
128
|
+
expect(catalyst.directoryPath).not.toContain('./');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('loadCatalystSafe', () => {
|
|
134
|
+
it('returns success result for valid catalyst', async () => {
|
|
135
|
+
const result = await loadCatalystSafe(COMPLETE_CATALYST);
|
|
136
|
+
|
|
137
|
+
expect(result.success).toBe(true);
|
|
138
|
+
expect(result.catalyst).toBeDefined();
|
|
139
|
+
expect(result.error).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns error result for invalid catalyst', async () => {
|
|
143
|
+
const result = await loadCatalystSafe(INVALID_CATALYST);
|
|
144
|
+
|
|
145
|
+
expect(result.success).toBe(false);
|
|
146
|
+
expect(result.catalyst).toBeUndefined();
|
|
147
|
+
expect(result.error).toBeDefined();
|
|
148
|
+
expect(result.error).toContain('manifest not found');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns error result for non-existent directory', async () => {
|
|
152
|
+
const result = await loadCatalystSafe('/nonexistent/path');
|
|
153
|
+
|
|
154
|
+
expect(result.success).toBe(false);
|
|
155
|
+
expect(result.error).toContain('not found');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('resolveCatalysts', () => {
|
|
160
|
+
it('resolves multiple catalysts', async () => {
|
|
161
|
+
const catalysts = await resolveCatalysts(
|
|
162
|
+
['./tests/fixtures/complete-catalyst', './tests/fixtures/partial-catalyst'],
|
|
163
|
+
process.cwd()
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(catalysts).toHaveLength(2);
|
|
167
|
+
expect(catalysts[0].manifest.id).toBe('@test/complete-catalyst');
|
|
168
|
+
expect(catalysts[1].manifest.id).toBe('@test/partial-catalyst');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('resolves relative to base path', async () => {
|
|
172
|
+
const catalysts = await resolveCatalysts(
|
|
173
|
+
['complete-catalyst', 'partial-catalyst'],
|
|
174
|
+
join(process.cwd(), 'tests/fixtures')
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
expect(catalysts).toHaveLength(2);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('throws error if any catalyst fails to load', async () => {
|
|
181
|
+
await expect(
|
|
182
|
+
resolveCatalysts(
|
|
183
|
+
['./tests/fixtures/complete-catalyst', '/nonexistent'],
|
|
184
|
+
process.cwd()
|
|
185
|
+
)
|
|
186
|
+
).rejects.toThrow('Failed to load catalyst');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('returns empty array for empty identifiers', async () => {
|
|
190
|
+
const catalysts = await resolveCatalysts([], process.cwd());
|
|
191
|
+
expect(catalysts).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('preserves order of catalysts', async () => {
|
|
195
|
+
const catalysts = await resolveCatalysts(
|
|
196
|
+
['./tests/fixtures/partial-catalyst', './tests/fixtures/complete-catalyst'],
|
|
197
|
+
process.cwd()
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(catalysts[0].manifest.id).toBe('@test/partial-catalyst');
|
|
201
|
+
expect(catalysts[1].manifest.id).toBe('@test/complete-catalyst');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('facet loading details', () => {
|
|
206
|
+
it('only loads .md files', async () => {
|
|
207
|
+
const catalyst = await loadCatalyst(COMPLETE_CATALYST);
|
|
208
|
+
|
|
209
|
+
// All loaded files should end with .md
|
|
210
|
+
for (const facetContent of Object.values(catalyst.facets)) {
|
|
211
|
+
if (facetContent) {
|
|
212
|
+
for (const file of facetContent) {
|
|
213
|
+
expect(file.filename).toMatch(/\.md$/);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('loads file content as strings', async () => {
|
|
220
|
+
const catalyst = await loadCatalyst(COMPLETE_CATALYST);
|
|
221
|
+
|
|
222
|
+
for (const facetContent of Object.values(catalyst.facets)) {
|
|
223
|
+
if (facetContent) {
|
|
224
|
+
for (const file of facetContent) {
|
|
225
|
+
expect(typeof file.content).toBe('string');
|
|
226
|
+
expect(file.content.length).toBeGreaterThan(0);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('preserves file content exactly', async () => {
|
|
233
|
+
const catalyst = await loadCatalyst(COMPLETE_CATALYST);
|
|
234
|
+
|
|
235
|
+
const testing = catalyst.facets.constraints?.find(
|
|
236
|
+
f => f.filename === 'testing.md'
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
expect(testing?.content).toContain('# Testing Constraints');
|
|
240
|
+
expect(testing?.content).toContain('Every plan must include a testing step');
|
|
241
|
+
expect(testing?.content).toContain('Coverage threshold: 80%');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadCatalyst } from '@/loader/catalyst-loader';
|
|
4
|
+
import {
|
|
5
|
+
mergeCatalysts,
|
|
6
|
+
renderFacet,
|
|
7
|
+
renderAllFacets,
|
|
8
|
+
summarizeMerge,
|
|
9
|
+
} from '@/merger/facet-merger';
|
|
10
|
+
|
|
11
|
+
const FIXTURES_DIR = join(__dirname, 'fixtures');
|
|
12
|
+
const COMPLETE_CATALYST = join(FIXTURES_DIR, 'complete-catalyst');
|
|
13
|
+
const PARTIAL_CATALYST = join(FIXTURES_DIR, 'partial-catalyst');
|
|
14
|
+
|
|
15
|
+
let completeCatalyst: Awaited<ReturnType<typeof loadCatalyst>>;
|
|
16
|
+
let partialCatalyst: Awaited<ReturnType<typeof loadCatalyst>>;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
completeCatalyst = await loadCatalyst(COMPLETE_CATALYST);
|
|
20
|
+
partialCatalyst = await loadCatalyst(PARTIAL_CATALYST);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('mergeCatalysts', () => {
|
|
24
|
+
describe('merging single catalyst', () => {
|
|
25
|
+
it('merges a single catalyst (passthrough)', () => {
|
|
26
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
27
|
+
|
|
28
|
+
expect(merged.catalystIds).toEqual(['@test/complete-catalyst']);
|
|
29
|
+
expect(merged.facets.questions).toBeDefined();
|
|
30
|
+
expect(merged.facets.constraints).toBeDefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('preserves source attribution', () => {
|
|
34
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
35
|
+
|
|
36
|
+
const questions = merged.facets.questions;
|
|
37
|
+
expect(questions).toBeDefined();
|
|
38
|
+
for (const item of questions || []) {
|
|
39
|
+
expect(item.sourceId).toBe('@test/complete-catalyst');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('tracks contribution metadata', () => {
|
|
44
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
45
|
+
|
|
46
|
+
const contrib = merged.contributions.get('@test/complete-catalyst');
|
|
47
|
+
expect(contrib).toBeDefined();
|
|
48
|
+
expect(contrib?.facetTypes).toContain('questions');
|
|
49
|
+
expect(contrib?.facetTypes).toContain('constraints');
|
|
50
|
+
expect(contrib?.contentCount).toBeGreaterThan(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('merging multiple catalysts', () => {
|
|
55
|
+
it('merges two catalysts in order', () => {
|
|
56
|
+
const merged = mergeCatalysts([completeCatalyst, partialCatalyst]);
|
|
57
|
+
|
|
58
|
+
expect(merged.catalystIds).toEqual([
|
|
59
|
+
'@test/complete-catalyst',
|
|
60
|
+
'@test/partial-catalyst',
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('preserves order of content', () => {
|
|
65
|
+
const merged = mergeCatalysts([completeCatalyst, partialCatalyst]);
|
|
66
|
+
|
|
67
|
+
const questions = merged.facets.questions;
|
|
68
|
+
expect(questions).toBeDefined();
|
|
69
|
+
|
|
70
|
+
// First items should be from complete-catalyst
|
|
71
|
+
const firstFromComplete = questions?.filter(
|
|
72
|
+
q => q.sourceId === '@test/complete-catalyst'
|
|
73
|
+
);
|
|
74
|
+
const firstFromPartial = questions?.filter(
|
|
75
|
+
q => q.sourceId === '@test/partial-catalyst'
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Complete catalyst content should come first
|
|
79
|
+
if (firstFromComplete && firstFromPartial && firstFromComplete.length > 0) {
|
|
80
|
+
const completeIndex = questions?.indexOf(firstFromComplete[0]) || -1;
|
|
81
|
+
const partialIndex = questions?.indexOf(firstFromPartial[0]) || -1;
|
|
82
|
+
expect(completeIndex).toBeLessThan(partialIndex);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('merges overlapping facets (same facet type)', () => {
|
|
87
|
+
const merged = mergeCatalysts([completeCatalyst, partialCatalyst]);
|
|
88
|
+
|
|
89
|
+
// Both catalysts have questions
|
|
90
|
+
const questions = merged.facets.questions;
|
|
91
|
+
expect(questions).toBeDefined();
|
|
92
|
+
expect(questions!.length).toBeGreaterThan(0);
|
|
93
|
+
|
|
94
|
+
// Should include content from both
|
|
95
|
+
const fromComplete = questions?.some(q => q.sourceId === '@test/complete-catalyst');
|
|
96
|
+
const fromPartial = questions?.some(q => q.sourceId === '@test/partial-catalyst');
|
|
97
|
+
|
|
98
|
+
expect(fromComplete).toBe(true);
|
|
99
|
+
expect(fromPartial).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('handles catalysts with different facets', () => {
|
|
103
|
+
const merged = mergeCatalysts([completeCatalyst, partialCatalyst]);
|
|
104
|
+
|
|
105
|
+
// Complete has all facets, partial has only questions and constraints
|
|
106
|
+
expect(merged.facets.outputTemplates).toBeDefined();
|
|
107
|
+
expect(merged.facets.domainKnowledge).toBeDefined();
|
|
108
|
+
expect(merged.facets.processGuidance).toBeDefined();
|
|
109
|
+
expect(merged.facets.validationRules).toBeDefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('tracks contributions from multiple catalysts', () => {
|
|
113
|
+
const merged = mergeCatalysts([completeCatalyst, partialCatalyst]);
|
|
114
|
+
|
|
115
|
+
expect(merged.contributions.size).toBe(2);
|
|
116
|
+
|
|
117
|
+
const completeContrib = merged.contributions.get('@test/complete-catalyst');
|
|
118
|
+
const partialContrib = merged.contributions.get('@test/partial-catalyst');
|
|
119
|
+
|
|
120
|
+
expect(completeContrib).toBeDefined();
|
|
121
|
+
expect(partialContrib).toBeDefined();
|
|
122
|
+
expect(completeContrib?.contentCount).toBeGreaterThan(0);
|
|
123
|
+
expect(partialContrib?.contentCount).toBeGreaterThan(0);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('edge cases', () => {
|
|
128
|
+
it('handles empty array of catalysts', () => {
|
|
129
|
+
const merged = mergeCatalysts([]);
|
|
130
|
+
|
|
131
|
+
expect(merged.catalystIds).toEqual([]);
|
|
132
|
+
expect(merged.facets).toEqual({});
|
|
133
|
+
expect(merged.contributions.size).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('handles catalysts with no facets', () => {
|
|
137
|
+
// This would require a catalyst with no facet directories
|
|
138
|
+
// For now, we'll test with existing catalysts
|
|
139
|
+
const merged = mergeCatalysts([partialCatalyst]);
|
|
140
|
+
|
|
141
|
+
// Partial catalyst only has questions and constraints
|
|
142
|
+
expect(merged.facets.outputTemplates).toBeUndefined();
|
|
143
|
+
expect(merged.facets.domainKnowledge).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('skips facets that are undefined in all catalysts', () => {
|
|
147
|
+
// Create scenario with no output templates
|
|
148
|
+
const merged = mergeCatalysts([partialCatalyst]);
|
|
149
|
+
|
|
150
|
+
// No facet type should be present if no catalyst provides it
|
|
151
|
+
for (const [key, value] of Object.entries(merged.facets)) {
|
|
152
|
+
if (value === undefined) {
|
|
153
|
+
expect(value).toBeUndefined();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('content preservation', () => {
|
|
160
|
+
it('preserves exact content from source files', () => {
|
|
161
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
162
|
+
|
|
163
|
+
const questions = merged.facets.questions;
|
|
164
|
+
expect(questions).toBeDefined();
|
|
165
|
+
|
|
166
|
+
// Should have content from exploration.md
|
|
167
|
+
const exploration = questions?.find(q => q.filename === 'exploration.md');
|
|
168
|
+
expect(exploration).toBeDefined();
|
|
169
|
+
expect(exploration?.content).toContain('Exploration Questions');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('preserves filename information', () => {
|
|
173
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
174
|
+
|
|
175
|
+
const questions = merged.facets.questions;
|
|
176
|
+
expect(questions).toBeDefined();
|
|
177
|
+
|
|
178
|
+
for (const item of questions || []) {
|
|
179
|
+
expect(item.filename).toBeDefined();
|
|
180
|
+
expect(item.filename).toMatch(/\.md$/);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('renderFacet', () => {
|
|
187
|
+
it('renders a facet with source attribution', () => {
|
|
188
|
+
const merged = mergeCatalysts([completeCatalyst, partialCatalyst]);
|
|
189
|
+
|
|
190
|
+
const rendered = renderFacet(merged, 'questions');
|
|
191
|
+
|
|
192
|
+
expect(rendered).toContain('From @test/complete-catalyst:');
|
|
193
|
+
expect(rendered).toContain('From @test/partial-catalyst:');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('includes content in rendered output', () => {
|
|
197
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
198
|
+
|
|
199
|
+
const rendered = renderFacet(merged, 'constraints');
|
|
200
|
+
|
|
201
|
+
expect(rendered).toContain('Testing');
|
|
202
|
+
expect(rendered).toContain('Documentation');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('returns empty string for missing facet', () => {
|
|
206
|
+
const merged = mergeCatalysts([partialCatalyst]);
|
|
207
|
+
|
|
208
|
+
const rendered = renderFacet(merged, 'outputTemplates');
|
|
209
|
+
|
|
210
|
+
expect(rendered).toBe('');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('separates sources with blank lines', () => {
|
|
214
|
+
const merged = mergeCatalysts([completeCatalyst, partialCatalyst]);
|
|
215
|
+
|
|
216
|
+
const rendered = renderFacet(merged, 'questions');
|
|
217
|
+
|
|
218
|
+
// Should have blank line between sources
|
|
219
|
+
expect(rendered).toContain('\n\nFrom @test/partial-catalyst:');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('handles single catalyst without extra blank lines', () => {
|
|
223
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
224
|
+
|
|
225
|
+
const rendered = renderFacet(merged, 'questions');
|
|
226
|
+
|
|
227
|
+
// Should start with source header, no leading blank line
|
|
228
|
+
expect(rendered).toMatch(/^From @test\/complete-catalyst:/);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('renderAllFacets', () => {
|
|
233
|
+
it('renders all facet types', () => {
|
|
234
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
235
|
+
|
|
236
|
+
const rendered = renderAllFacets(merged);
|
|
237
|
+
|
|
238
|
+
expect(rendered.questions).toBeDefined();
|
|
239
|
+
expect(rendered.constraints).toBeDefined();
|
|
240
|
+
expect(rendered.outputTemplates).toBeDefined();
|
|
241
|
+
expect(rendered.domainKnowledge).toBeDefined();
|
|
242
|
+
expect(rendered.processGuidance).toBeDefined();
|
|
243
|
+
expect(rendered.validationRules).toBeDefined();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('returns all facets as strings', () => {
|
|
247
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
248
|
+
|
|
249
|
+
const rendered = renderAllFacets(merged);
|
|
250
|
+
|
|
251
|
+
for (const value of Object.values(rendered)) {
|
|
252
|
+
expect(typeof value).toBe('string');
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('includes empty strings for missing facets', () => {
|
|
257
|
+
const merged = mergeCatalysts([partialCatalyst]);
|
|
258
|
+
|
|
259
|
+
const rendered = renderAllFacets(merged);
|
|
260
|
+
|
|
261
|
+
expect(rendered.outputTemplates).toBe('');
|
|
262
|
+
expect(rendered.domainKnowledge).toBe('');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('summarizeMerge', () => {
|
|
267
|
+
it('summarizes a single catalyst', () => {
|
|
268
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
269
|
+
|
|
270
|
+
const summary = summarizeMerge(merged);
|
|
271
|
+
|
|
272
|
+
expect(summary).toContain('Merged 1 catalyst');
|
|
273
|
+
expect(summary).toContain('@test/complete-catalyst');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('summarizes multiple catalysts', () => {
|
|
277
|
+
const merged = mergeCatalysts([completeCatalyst, partialCatalyst]);
|
|
278
|
+
|
|
279
|
+
const summary = summarizeMerge(merged);
|
|
280
|
+
|
|
281
|
+
expect(summary).toContain('Merged 2 catalyst');
|
|
282
|
+
expect(summary).toContain('@test/complete-catalyst');
|
|
283
|
+
expect(summary).toContain('@test/partial-catalyst');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('includes facet types in summary', () => {
|
|
287
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
288
|
+
|
|
289
|
+
const summary = summarizeMerge(merged);
|
|
290
|
+
|
|
291
|
+
expect(summary).toContain('Facets:');
|
|
292
|
+
expect(summary).toContain('Questions');
|
|
293
|
+
expect(summary).toContain('Constraints');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('includes content count in summary', () => {
|
|
297
|
+
const merged = mergeCatalysts([completeCatalyst]);
|
|
298
|
+
|
|
299
|
+
const summary = summarizeMerge(merged);
|
|
300
|
+
|
|
301
|
+
expect(summary).toContain('Content items:');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('handles empty merge', () => {
|
|
305
|
+
const merged = mergeCatalysts([]);
|
|
306
|
+
|
|
307
|
+
const summary = summarizeMerge(merged);
|
|
308
|
+
|
|
309
|
+
expect(summary).toBe('No catalysts merged');
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
id: "@test/complete-catalyst"
|
|
2
|
+
name: Complete Test Catalyst
|
|
3
|
+
description: A test catalyst with all facets for testing purposes
|
|
4
|
+
version: "1.0.0"
|
|
5
|
+
facets:
|
|
6
|
+
questions: true
|
|
7
|
+
constraints: true
|
|
8
|
+
outputTemplates: true
|
|
9
|
+
domainKnowledge: true
|
|
10
|
+
processGuidance: true
|
|
11
|
+
validationRules: true
|