Skip to content

Commit a547fd4

Browse files
feat: map Compass DEPENDS_ON relationships to service markdown (Phase 3) (#144)
* feat: map Compass DEPENDS_ON relationships to service markdown (Phase 3) Parse relationships.DEPENDS_ON from Compass YAML and resolve referenced services using a two-pass approach. First pass collects all services into a map (Compass ARN → service ID), second pass resolves dependencies and generates markdown with linked dependency sections. - Add ResolvedDependency type to src/types.ts - Update defaultMarkdown() to render Dependencies section with links - Update loadService() to accept optional dependencies parameter - Implement two-pass service processing in index.ts for dependency resolution - Handle missing dependency targets gracefully (log warning, don't crash) - Update test fixtures with cross-referencing DEPENDS_ON ARNs - Add 6 new tests covering relationship resolution scenarios - Mark Phase 3 as complete in PLAN.md https://claude.ai/code/session_01Nciw9idSbSrmH2PU7y8J7a * chore: update pnpm-lock.yaml and add pnpm-workspace.yaml https://claude.ai/code/session_01Nciw9idSbSrmH2PU7y8J7a * fix: address PR review - eliminate duplicate YAML parsing and typeFilter logic - Cache parsed configs in first pass, reuse in second pass instead of calling loadConfig() twice per service - Consolidate typeFilter check into first pass only (was duplicated) - Remove unused `config` field from serviceMap entries - Remove redundant serviceId re-computation in second pass https://claude.ai/code/session_01Nciw9idSbSrmH2PU7y8J7a * fix: remove pnpm v10 artifacts breaking CI with pnpm v8 Remove pnpm-workspace.yaml (pnpm v10 feature) and restore the original pnpm-lock.yaml from master. CI uses pnpm v8 which doesn't support the ignoredBuiltDependencies config or the libc metadata added by pnpm v10. CI's pnpm install will resolve any new dependencies from package.json. https://claude.ai/code/session_01Nciw9idSbSrmH2PU7y8J7a * fix: escape HTML special chars in sanitizeMarkdownText to prevent XSS The sanitizeMarkdownText function only escaped markdown link characters (brackets and parentheses) but not HTML special characters. In an MDX context, a malicious dependency name like <script>alert(1)</script> would be rendered as executable HTML. Now escapes &, <, >, ", and ' as HTML entities before escaping markdown syntax, preventing XSS in generated service markdown. https://claude.ai/code/session_01Nciw9idSbSrmH2PU7y8J7a * docs: initialise CLAUDE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: format CLAUDE.md with prettier Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c5d7496 commit a547fd4

File tree

10 files changed

+307
-94
lines changed

10 files changed

+307
-94
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@ismaelmartinez/generator-atlassian-compass-event-catalog': minor
3+
---
4+
5+
feat: map Compass DEPENDS_ON relationships to service markdown
6+
7+
- Parse `relationships.DEPENDS_ON` from Compass YAML and resolve referenced services
8+
- Add a "Dependencies" section to generated service markdown with links to dependent services
9+
- Use a two-pass approach: first collect all services, then resolve dependencies between them
10+
- Handle missing dependency targets gracefully (log warning, don't crash)
11+
- Services without dependencies show "No known dependencies."

CLAUDE.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
An EventCatalog generator plugin that reads Atlassian Compass YAML files and produces service entries in an EventCatalog. It parses `compass.yml` files, extracts metadata (links, badges, owners, dependencies), and uses the `@eventcatalog/sdk` to write services (and optionally domains) into the catalog's file system.
8+
9+
## Commands
10+
11+
Package manager is pnpm (v8 in CI). All commands use `pnpm run`.
12+
13+
```
14+
pnpm install # install deps
15+
pnpm run build # build with tsup (CJS + ESM + .d.ts into dist/)
16+
pnpm run test # run vitest in watch mode
17+
pnpm run test -- run # single test run (no watch)
18+
pnpm run lint # eslint
19+
pnpm run lint:fix # eslint with auto-fix
20+
pnpm run format:diff # prettier check (CI uses this)
21+
pnpm run format # prettier write
22+
```
23+
24+
CI runs three checks on PRs: Tests, Lint (eslint + prettier), and Verify Build. All must pass.
25+
26+
## Architecture
27+
28+
The plugin exports a single async function from `src/index.ts` that EventCatalog calls with a config object and `GeneratorProps` options. Processing happens in two passes over the services array:
29+
30+
Pass 1 (`src/index.ts`): Load each compass YAML via `loadConfig` from `src/compass.ts`, apply the optional `typeFilter`, and build a `serviceMap` keyed by Compass ARN so that `DEPENDS_ON` relationships can be resolved between services.
31+
32+
Pass 2 (`src/index.ts`): For each processable file, call `loadService` from `src/service.ts` to build the `Service` object (markdown template with links, dependencies, badges, owners, repository URL), then write it via the EventCatalog SDK. If a `domain` option is provided, `src/domain.ts` handles creating/versioning the domain and associating services to it.
33+
34+
Key modules:
35+
36+
- `src/compass.ts``CompassConfig` type definition and YAML loading. The type mirrors the Atlassian Compass config-as-code spec.
37+
- `src/service.ts` — Transforms a `CompassConfig` into an EventCatalog `Service`. Contains markdown template generation, badge building, URL sanitization (XSS prevention), and link formatting.
38+
- `src/domain.ts``Domain` class that manages domain creation, versioning, and service association via the SDK.
39+
- `src/validation.ts` — Zod schemas for validating `GeneratorProps` at runtime.
40+
- `src/types.ts` — Shared TypeScript types (`GeneratorProps`, `DomainOption`, `ResolvedDependency`).
41+
42+
## Testing
43+
44+
Single test file at `src/test/plugin.test.ts`. Tests run the full plugin against a temporary catalog directory (`src/test/catalog/`) that gets cleaned up in `afterEach`. Test fixture YAML files live in `src/test/`. The `vitest.setup.ts` adds a custom `toMatchMarkdown` matcher that normalizes whitespace.
45+
46+
The `@eventcatalog/sdk` is inlined during testing (configured in `vitest.config.ts` via `server.deps.inline`).
47+
48+
## Security Considerations
49+
50+
`src/service.ts` sanitizes all user-controlled text before embedding in markdown/MDX: `sanitizeMarkdownText` escapes HTML special characters and markdown link syntax; `sanitizeUrl` only allows `http:`/`https:` protocols. `src/index.ts` sanitizes service IDs via `sanitizeId` to prevent path traversal.

PLAN.md

Lines changed: 42 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Evolve this generator from a "Compass YAML file reader" into a full "Compass int
66

77
---
88

9-
## Phase 1: Modernize & Fix Core Gaps
9+
## Phase 1: Modernize & Fix Core Gaps ✅ COMPLETE
1010

1111
**Release**: v0.1.0 (minor -- new behavior: updates instead of skips)
1212
**Effort**: Small
@@ -136,7 +136,7 @@ export const GeneratorPropsSchema = z.object({
136136

137137
---
138138

139-
## Phase 2: Support All Compass Component Types
139+
## Phase 2: Support All Compass Component Types ✅ COMPLETE
140140

141141
**Release**: v0.2.0
142142
**Effort**: Medium
@@ -179,80 +179,42 @@ Add a badge showing the Compass component type: `{ content: "APPLICATION", backg
179179

180180
---
181181

182-
## Phase 3: Relationship Mapping
182+
## Phase 3: Relationship Mapping ✅ COMPLETE
183183

184184
**Release**: v0.3.0
185185
**Effort**: Medium
186186

187-
### 3.1 — Parse and map DEPENDS_ON relationships
187+
### 3.1 — Parse and map DEPENDS_ON relationships
188188

189-
**File**: `src/service.ts` or new file `src/relationships.ts`
189+
**Files modified**: `src/types.ts`, `src/service.ts`, `src/index.ts`
190190

191-
Compass YAML already has:
191+
**What was implemented:**
192192

193-
```yaml
194-
relationships:
195-
DEPENDS_ON:
196-
- 'ari:cloud:compass:...'
197-
```
198-
199-
**Important**: The SDK does **not** have a generic `addRelationshipToService()` function. The SDK models relationships through specific mechanisms:
200-
201-
- `sends` / `receives` — for message flows (events, commands, queries between services via channels)
202-
- `writesTo` / `readsFrom` — for data store relationships
203-
- `<NodeGraph />` — renders the service/domain graph visually in markdown
204-
205-
Compass `DEPENDS_ON` is a general architectural dependency, not a message flow. It doesn't map cleanly to `sends`/`receives` (which are for events/commands, not service-to-service dependencies). Using `addEventToService()` would be incorrect here — that function is for linking events to services, not for expressing that one service depends on another.
206-
207-
**Approach**: Express dependencies through the service markdown and the domain's `<NodeGraph />`. The `<NodeGraph />` component already renders all services within a domain and their connections. We enrich each service's markdown with an explicit "Dependencies" section listing what it depends on, and ensure both services are in the same domain so the graph visualizes the relationship.
193+
- Added `ResolvedDependency` type (`{ id: string; name: string }`) to `src/types.ts`
194+
- Updated `defaultMarkdown()` in `src/service.ts` to accept and render a `dependencies` parameter, generating a "Dependencies" section with links to dependent services (or "No known dependencies." when none exist)
195+
- Updated `loadService()` to accept optional `dependencies` parameter and pass it through to markdown generation
196+
- Implemented two-pass approach in `src/index.ts`:
197+
1. **First pass**: Collects all services being processed into a Map (Compass ARN → service ID + name), respecting `typeFilter`
198+
2. **Second pass**: For each service, resolves `DEPENDS_ON` ARNs against the map, logs warnings for unresolvable dependencies, and writes services with resolved dependency markdown
199+
- Missing dependency targets are handled gracefully (warning logged, not crashed)
200+
- Dependency text in markdown is sanitized against injection
208201

209-
1. Collect all service IDs being processed in a Map (Compass ARN → EventCatalog ID)
210-
2. For each service, resolve its `DEPENDS_ON` ARNs to EventCatalog service IDs
211-
3. Include the dependency list in the service's generated markdown
212-
4. If both services are in the same domain, the `<NodeGraph />` will show them together
202+
### 3.2 — Tests for Phase 3 ✅
213203

214-
**File**: `src/service.ts`
215-
216-
Add a dependencies section to the generated markdown:
217-
218-
```ts
219-
// In defaultMarkdown(), add after links section:
220-
const dependencyLinks = dependencies
221-
?.map((dep) => `* [${dep.name}](/docs/services/${dep.id})`)
222-
.join('\n');
223-
224-
// Include in markdown:
225-
## Dependencies
226-
${dependencyLinks || 'No known dependencies.'}
227-
```
204+
Six new tests added to `src/test/plugin.test.ts`:
228205

229-
**File**: `src/index.ts`
230-
231-
Build the service map and resolve dependencies before generating markdown:
232-
233-
```ts
234-
// First pass: collect all services into a map
235-
const serviceMap = new Map<string, { serviceId: string; config: CompassConfig }>();
236-
for (const file of compassFiles) {
237-
const config = loadConfig(file.path);
238-
serviceMap.set(config.id || '', { serviceId: file.id || config.name, config });
239-
}
240-
241-
// Second pass: write services with resolved dependencies
242-
for (const file of compassFiles) {
243-
const config = loadConfig(file.path);
244-
const dependencies = (config.relationships?.DEPENDS_ON || []).map((arn) => serviceMap.get(arn)).filter(Boolean);
245-
246-
const service = loadService(config, compassUrl, file.version, file.id, dependencies);
247-
await writeService(service);
248-
}
249-
```
206+
- ✅ Resolves dependencies between services processed together (my-service → my-application, my-library)
207+
- ✅ Resolves partial dependencies when only some targets are processed
208+
- ✅ Shows "No known dependencies" for services without DEPENDS_ON
209+
- ✅ Handles missing dependency targets gracefully without crashing
210+
- ✅ Resolves dependencies correctly when services have custom IDs
211+
- ✅ Includes resolved dependencies in service markdown within a domain
250212

251-
### 3.2 — Tests for Phase 3
213+
Test fixtures updated with cross-referencing DEPENDS_ON ARNs:
252214

253-
- Add fixture with two services that have `DEPENDS_ON` pointing to each other
254-
- Test that relationships are logged/processed
255-
- Test that missing dependency targets are handled gracefully (log warning, don't crash)
215+
- `my-service-compass.yml` → depends on `my-application` and `my-library`
216+
- `my-application-compass.yml` → depends on `my-library`
217+
- `my-other-compass.notsupported.yml` → depends on `my-application` and a non-existent ARN (for graceful failure testing)
256218

257219
---
258220

@@ -433,13 +395,13 @@ If Compass provides team/owner data via the API, use `writeTeam()` and `writeUse
433395

434396
## Release Schedule
435397

436-
| Phase | Version | Breaking? | What ships |
437-
| ----- | ------- | ----------------------------------------------------- | ------------------------------------------- |
438-
| 1 | 0.1.0 | No (new default `overrideExisting: true` is additive) | Richer services, update support, validation |
439-
| 2 | 0.2.0 | No (previously errored types now work) | All Compass types supported |
440-
| 3 | 0.3.0 | No | Relationship mapping |
441-
| 4 | 0.4.0 | No (API mode is opt-in) | Compass API integration |
442-
| 5 | 0.5.0 | No | OpenAPI specs, scorecards, MDX, teams |
398+
| Phase | Version | Breaking? | What ships | Status |
399+
| ----- | ------- | ----------------------------------------------------- | ------------------------------------------- | ----------- |
400+
| 1 | 0.1.0 | No (new default `overrideExisting: true` is additive) | Richer services, update support, validation | ✅ Complete |
401+
| 2 | 0.2.0 | No (previously errored types now work) | All Compass types supported | ✅ Complete |
402+
| 3 | 0.3.0 | No | Relationship mapping | ✅ Complete |
403+
| 4 | 0.4.0 | No (API mode is opt-in) | Compass API integration | ⏳ Planned |
404+
| 5 | 0.5.0 | No | OpenAPI specs, scorecards, MDX, teams | ⏳ Planned |
443405

444406
---
445407

@@ -448,19 +410,19 @@ If Compass provides team/owner data via the API, use `writeTeam()` and `writeUse
448410
```
449411
src/
450412
├── index.ts # Main generator entry point (modified in phases 1-4)
451-
├── types.ts # GeneratorProps, re-export SDK types (modified in phases 1-2, 4)
413+
├── types.ts # GeneratorProps, ResolvedDependency, re-export SDK types (modified in phases 1-3, 4)
452414
├── compass.ts # YAML parser + CompassConfig type (modified in phase 2)
453415
├── compass-api.ts # NEW: Compass GraphQL API client (phase 4)
454-
├── service.ts # loadService with badge/metadata mapping (modified in phases 1-3)
416+
├── service.ts # loadService with badge/metadata/dependency mapping (modified in phases 1-3)
455417
├── domain.ts # Domain processing (minimal changes)
456-
├── relationships.ts # NEW: relationship mapping logic (phase 3)
457-
├── validation.ts # NEW: Zod schemas (phase 1)
418+
├── validation.ts # Zod schemas (phase 1)
458419
└── test/
459-
├── plugin.test.ts # Main tests (extended each phase)
460-
├── my-service-compass.yml # Existing fixture
461-
├── my-other-compass.notsupported.yml # Existing fixture (rename in phase 2)
462-
├── my-app-compass.yml # NEW fixture (phase 2)
463-
├── my-library-compass.yml # NEW fixture (phase 2)
420+
├── plugin.test.ts # Main tests (extended each phase, 34 tests)
421+
├── my-service-compass.yml # SERVICE fixture (with DEPENDS_ON refs)
422+
├── my-application-compass.yml # APPLICATION fixture (with DEPENDS_ON refs)
423+
├── my-library-compass.yml # LIBRARY fixture
424+
├── my-capability-compass.yml # CAPABILITY fixture
425+
├── my-other-compass.notsupported.yml # OTHER fixture (with partial DEPENDS_ON refs)
464426
└── compass-api.test.ts # NEW: API client tests (phase 4)
465427
```
466428

src/index.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import chalk from 'chalk';
33
import { loadConfig, CompassConfig } from './compass';
44
import { loadService } from './service';
55
import Domain from './domain';
6-
import { GeneratorProps } from './types';
6+
import { GeneratorProps, ResolvedDependency } from './types';
77
import { GeneratorPropsSchema } from './validation';
88

99
// Sanitize IDs to prevent path traversal from untrusted sources
@@ -43,6 +43,11 @@ export default async (_config: EventCatalogConfig, options: GeneratorProps) => {
4343
await domain.processDomain();
4444
}
4545

46+
// First pass: load all configs, apply typeFilter, and collect into a map (Compass ARN → service info)
47+
// This allows resolving DEPENDS_ON relationships between services and avoids parsing YAML twice
48+
const serviceMap = new Map<string, { serviceId: string; name: string }>();
49+
const processableFiles: { file: (typeof compassFiles)[number]; config: CompassConfig; serviceId: string }[] = [];
50+
4651
for (const file of compassFiles) {
4752
const compassConfig: CompassConfig = loadConfig(file.path);
4853

@@ -56,17 +61,43 @@ export default async (_config: EventCatalogConfig, options: GeneratorProps) => {
5661
}
5762
}
5863

59-
console.log(chalk.blue(`\nProcessing component: ${compassConfig.name} (type: ${compassConfig.typeId || 'unknown'})`));
60-
6164
const serviceId = sanitizeId(file.id || compassConfig.name);
65+
if (compassConfig.id) {
66+
serviceMap.set(compassConfig.id, { serviceId, name: compassConfig.name });
67+
}
68+
processableFiles.push({ file, config: compassConfig, serviceId });
69+
}
70+
71+
// Second pass: write services with resolved dependencies
72+
for (const { file, config: compassConfig, serviceId } of processableFiles) {
73+
console.log(chalk.blue(`\nProcessing component: ${compassConfig.name} (type: ${compassConfig.typeId || 'unknown'})`));
6274

6375
if (domain) {
6476
// Add the service to the domain
6577
await domain.addServiceToDomain(serviceId, file.version);
6678
}
6779

80+
// Resolve DEPENDS_ON relationships
81+
const dependencies: ResolvedDependency[] = [];
82+
if (compassConfig.relationships?.DEPENDS_ON) {
83+
for (const arn of compassConfig.relationships.DEPENDS_ON) {
84+
const target = serviceMap.get(arn);
85+
if (target) {
86+
dependencies.push({ id: target.serviceId, name: target.name });
87+
} else {
88+
console.log(chalk.yellow(` - Dependency ${arn} not found in processed services, skipping`));
89+
}
90+
}
91+
}
92+
6893
const existing = await getService(serviceId);
69-
const compassService = loadService(compassConfig, options.compassUrl.replace(/\/$/, ''), file.version, serviceId);
94+
const compassService = loadService(
95+
compassConfig,
96+
options.compassUrl.replace(/\/$/, ''),
97+
file.version,
98+
serviceId,
99+
dependencies
100+
);
70101

71102
if (existing && options.overrideExisting !== false) {
72103
await writeService(compassService, { override: true });

src/service.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { CompassConfig } from './compass';
2-
import { Service, Badge } from './types';
2+
import { Service, Badge, ResolvedDependency } from './types';
33

4-
// Sanitize text for safe markdown embedding: escape brackets and parentheses
4+
// Sanitize text for safe markdown/MDX embedding: escape HTML special chars and markdown link syntax
55
function sanitizeMarkdownText(text: string): string {
6-
return text.replace(/[[\]()]/g, (char) => `\\${char}`);
6+
return text
7+
.replace(/&/g, '&amp;')
8+
.replace(/</g, '&lt;')
9+
.replace(/>/g, '&gt;')
10+
.replace(/"/g, '&quot;')
11+
.replace(/'/g, '&#39;')
12+
.replace(/[[\]()]/g, (char) => `\\${char}`);
713
}
814

915
// Sanitize URL for safe markdown link embedding: only allow http/https protocols
@@ -93,7 +99,12 @@ function getOwners(config: CompassConfig): string[] {
9399
return [teamId];
94100
}
95101

96-
export const defaultMarkdown = (config: CompassConfig, compassComponentUrl?: string, compassTeamUrl?: string) => {
102+
export const defaultMarkdown = (
103+
config: CompassConfig,
104+
compassComponentUrl?: string,
105+
compassTeamUrl?: string,
106+
dependencies?: ResolvedDependency[]
107+
) => {
97108
const safeComponentUrl = compassComponentUrl ? sanitizeUrl(compassComponentUrl) : '';
98109
const safeTeamUrl = compassTeamUrl ? sanitizeUrl(compassTeamUrl) : '';
99110

@@ -108,6 +119,11 @@ export const defaultMarkdown = (config: CompassConfig, compassComponentUrl?: str
108119
.filter(Boolean)
109120
.join('\n');
110121

122+
const dependencyLines =
123+
dependencies && dependencies.length > 0
124+
? dependencies.map((dep) => `* [${sanitizeMarkdownText(dep.name)}](/docs/services/${dep.id})`).join('\n')
125+
: 'No known dependencies.';
126+
111127
return `
112128
113129
## Links
@@ -116,6 +132,10 @@ export const defaultMarkdown = (config: CompassConfig, compassComponentUrl?: str
116132
* 🪂 [Compass Team](${safeTeamUrl})
117133
${linkLines}
118134
135+
## Dependencies
136+
137+
${dependencyLines}
138+
119139
## Architecture diagram
120140
121141
<NodeGraph />
@@ -143,9 +163,15 @@ export function loadService(
143163
config: CompassConfig,
144164
compassUrl: string,
145165
serviceVersion: string = '0.0.0',
146-
serviceId: string = config.name
166+
serviceId: string = config.name,
167+
dependencies?: ResolvedDependency[]
147168
): Service {
148-
const markdownTemplate = defaultMarkdown(config, getComponentUrl(compassUrl, config), getTeamUrl(compassUrl, config));
169+
const markdownTemplate = defaultMarkdown(
170+
config,
171+
getComponentUrl(compassUrl, config),
172+
getTeamUrl(compassUrl, config),
173+
dependencies
174+
);
149175
const badges = buildBadges(config);
150176
const repositoryUrl = getRepositoryUrl(config);
151177
const owners = getOwners(config);

src/test/my-application-compass.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ links:
1313
url: 'https://www.example.com/repos/my-application-repo'
1414
labels:
1515
- frontend
16+
relationships:
17+
DEPENDS_ON:
18+
- 'ari:cloud:compass:a0000000-b000-c000-d000-e00000000000:component/22222222-2222-2222-2222-222222222222/22222222-2222-2222-2222-222222222222'

src/test/my-other-compass.notsupported.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,5 @@ customFields:
3232
value: true
3333
relationships:
3434
DEPENDS_ON:
35-
- 'ari:cloud:compass:00000000-0000-0000-0000-000000000000:component/00000000-0000-0000-0000-000000000000/00000000-0000-0000-0000-00000000000'
36-
- 'ari:cloud:compass:00000000-0000-0000-0000-000000000000:component/00000000-0000-0000-0000-000000000000/00000000-0000-0000-0000-00000000000'
35+
- 'ari:cloud:compass:a0000000-b000-c000-d000-e00000000000:component/11111111-1111-1111-1111-111111111111/11111111-1111-1111-1111-111111111111'
36+
- 'ari:cloud:compass:99999999-9999-9999-9999-999999999999:component/99999999-9999-9999-9999-999999999999/99999999-9999-9999-9999-999999999999'

0 commit comments

Comments
 (0)