From 0be8d55ed8246c34be997dec270ff1d7fb68a821 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 21 Oct 2026 07:45:19 -0400 Subject: [PATCH 1/4] feat(studio): show strategic findings where they land on the domain chart Signed-off-by: Yordis Prieto --- tsworkspace/webapps/studio/server/index.mjs | 61 ++++- .../webapps/studio/server/index.test.mjs | 148 +++++++++++- .../src/components/OverviewPage.test.tsx | 96 +++++++- .../studio/src/components/OverviewPage.tsx | 36 ++- .../components/canvas/DomainChartBoard.tsx | 69 +++++- .../webapps/studio/src/lib/api.test.ts | 9 + tsworkspace/webapps/studio/src/lib/api.ts | 3 + .../studio/src/lib/domainchart.test.ts | 220 +++++++++++++++++- .../webapps/studio/src/lib/domainchart.ts | 162 ++++++++++++- .../webapps/studio/src/lib/issues.test.ts | 32 ++- tsworkspace/webapps/studio/src/lib/issues.ts | 24 ++ .../studio/src/lib/validation-context.test.ts | 107 +++++++++ .../studio/src/lib/validation-context.ts | 89 ++++++- 13 files changed, 1017 insertions(+), 39 deletions(-) diff --git a/tsworkspace/webapps/studio/server/index.mjs b/tsworkspace/webapps/studio/server/index.mjs index 19eedf00..f8630cec 100644 --- a/tsworkspace/webapps/studio/server/index.mjs +++ b/tsworkspace/webapps/studio/server/index.mjs @@ -1093,9 +1093,11 @@ app.get( handler(() => call("listEntityKinds", {})), ); -// Validate every EventModel in a project or domain in one call. Query: +// Validate every EventModel in a project, domain or namespace in one call. +// Query, exactly one scope: // ?projectNamespace=foo&projectSlug=foo -- scope by Project // ?domainNamespace=foo&domainSlug=foo -- scope by Domain +// ?namespace=foo -- scope by namespace app.get( "/api/validate-project", branchHandler(async (req) => { @@ -1108,6 +1110,7 @@ app.get( "domainNamespace", "domainSlug", "domainVersion", + "namespace", ]) { if (q[key] != null) { const val = String(q[key]); @@ -1129,12 +1132,16 @@ app.get( const domainId = q.domainNamespace && q.domainSlug ? { namespace: String(q.domainNamespace), slug: String(q.domainSlug), version: String(q.domainVersion ?? "1") } : null; - if (!projectId && !domainId) { - const err = /** @type {HttpError} */ (new Error("set projectNamespace+projectSlug OR domainNamespace+domainSlug")); + const namespaceScope = q.namespace ? String(q.namespace) : null; + const scopes = [projectId, domainId, namespaceScope].filter(Boolean).length; + if (scopes !== 1) { + const err = /** @type {HttpError} */ (new Error( + "set exactly one of projectNamespace+projectSlug, domainNamespace+domainSlug, or namespace", + )); err.status = 400; throw err; } - const args = projectId ? { projectId } : { domainId }; + const args = projectId ? { projectId } : domainId ? { domainId } : { namespace: namespaceScope }; return await call("validateProject", args); }), ); @@ -1711,6 +1718,52 @@ app.get( error: maskError(err), }); } + // Domains and Subdomains declared in this namespace, even when they are + // not on the realizes chain: a Domain declared in a context's namespace + // is the subject of a finding, and a finding on an entity that is not + // loaded cannot link anywhere. Then the Project each loaded Domain names. + try { + /** @param {unknown} e */ + const slugKey = (e) => envelopeKey(e)?.replace(/@[^@]*$/, ""); + const loaded = new Set(knowledge.map(slugKey)); + const declared = await paginateList((pageToken) => ({ + namespaces: [ns], + kinds: [17, 18], // DOMAIN, SUBDOMAIN + latestVersionsOnly: true, + pageSize: 500, + pageToken, + })); + for (const ent of declared) { + const key = slugKey(ent); + if (key && loaded.has(key)) continue; + if (key) loaded.add(key); + knowledge.push(ent); + } + /** @type {Map} */ + const projectRefs = new Map(); + for (const ent of knowledge) { + const pid = /** @type {any} */ (ent)?.domain?.project?.id; + if (pid?.namespace && pid?.slug) { + projectRefs.set(`${pid.namespace}/${pid.slug}`, { ...pid, version: pid.version || "1" }); + } + } + const projects = await limitedMap( + [...projectRefs.values()], + GRPC_FANOUT_CONCURRENCY, + async (/** @type {unknown} */ id) => { + try { + const r = /** @type {any} */ (await call("getEntity", { kind: 20, id })); // PROJECT + return r.entity; + } catch (err) { + logEvent("warn", "fanout_project_fetch_failed", { kind: 20, id: /** @type {any} */ (id), error: maskError(err) }); + return null; + } + }, + ); + for (const p of projects) if (p?.project) knowledge.push(p); + } catch (err) { + logEvent("warn", "namespace_strategic_fetch_failed", { error: maskError(err) }); + } return { entities: decodeAnnotations([ ...entities, diff --git a/tsworkspace/webapps/studio/server/index.test.mjs b/tsworkspace/webapps/studio/server/index.test.mjs index 8c4de350..b146e5bc 100644 --- a/tsworkspace/webapps/studio/server/index.test.mjs +++ b/tsworkspace/webapps/studio/server/index.test.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -532,6 +532,55 @@ describe('GET /api/validate-project', () => { const res = await request(app).get('/api/validate-project?projectNamespace=../bad&projectSlug=my-project'); expect(res.status).toBe(400); }); + + describe('namespace scope', () => { + let seen; + let original; + + beforeEach(() => { + original = fakeClient.validateProject; + seen = undefined; + fakeClient.validateProject = (req, _meta, _opts, cb) => { + seen = req; + cb(null, { reports: [], totalErrors: 0, totalWarnings: 0, totalInfo: 0 }); + }; + }); + + afterEach(() => { + fakeClient.validateProject = original; + }); + + it('maps ?namespace= to the namespace scope', async () => { + const res = await request(app).get('/api/validate-project?namespace=accounts'); + expect(res.status).toBe(200); + expect(seen).toEqual({ namespace: 'accounts' }); + }); + + it('still maps project and domain scopes to their ids', async () => { + await request(app).get('/api/validate-project?projectNamespace=acme&projectSlug=acme'); + expect(seen).toEqual({ projectId: { namespace: 'acme', slug: 'acme', version: '1' } }); + await request(app).get('/api/validate-project?domainNamespace=shop&domainSlug=shop&domainVersion=2'); + expect(seen).toEqual({ domainId: { namespace: 'shop', slug: 'shop', version: '2' } }); + }); + + it('returns 400 when the namespace is unsafe or too long', async () => { + expect((await request(app).get('/api/validate-project?namespace=../bad')).status).toBe(400); + expect((await request(app).get(`/api/validate-project?namespace=${'a'.repeat(5000)}`)).status).toBe(400); + expect(seen).toBeUndefined(); + }); + + it('returns 400 unless exactly one scope is set', async () => { + const both = await request(app).get( + '/api/validate-project?namespace=accounts&projectNamespace=acme&projectSlug=acme', + ); + expect(both.status).toBe(400); + expect(both.body.error).toMatch(/exactly one/); + expect(seen).toBeUndefined(); + const none = await request(app).get('/api/validate-project'); + expect(none.status).toBe(400); + expect(none.body.error).toMatch(/exactly one/); + }); + }); }); describe('GET /api/event-model/:namespace/:slug/manifests', () => { @@ -652,6 +701,103 @@ describe('GET /api/event-model/:namespace/:slug success path (members, halo, kno expect(res.body.entities.some((e) => e.domain)).toBe(true); }); + it('loads the domains and subdomains declared in the namespace, and the project a domain names', async () => { + const originalGetEntity = fakeClient.getEntity; + const originalListEntities = fakeClient.listEntities; + const listed = []; + fakeClient.getEntity = (req, _meta, _opts, cb) => { + const { kind, id } = req; + if (kind === 12) { + cb(null, { entity: { eventModel: { id, title: 'Stray model', members: [] } } }); + return; + } + if (kind === 16) { + cb(null, { + entity: { + boundedContext: { + id: { namespace: 'my-ns', slug: 'my-ns', version: '1' }, + title: 'My context', + realizes: [{ id: { namespace: 'problem', slug: 'billing', version: '1' } }], + }, + }, + }); + return; + } + if (kind === 18 && id.slug === 'billing') { + cb(null, { + entity: { + subdomain: { + id, + title: 'Billing', + domain: { id: { namespace: 'problem', slug: 'problem', version: '1' } }, + }, + }, + }); + return; + } + if (kind === 17 && id.namespace === 'problem') { + cb(null, { entity: { domain: { id, title: 'Problem' } } }); + return; + } + if (kind === 20) { + cb(null, { entity: { project: { id, title: 'Acme' } } }); + return; + } + cb(null, { entity: null }); + }; + fakeClient.listEntities = (req, _meta, _opts, cb) => { + listed.push(req); + const strayDomain = { + domain: { + id: { namespace: 'my-ns', slug: 'my-ns', version: '1' }, + title: 'Stray domain', + project: { id: { namespace: 'acme', slug: 'acme', version: '1' } }, + }, + }; + const parent = { + domain: { id: { namespace: 'problem', slug: 'problem', version: '2' }, title: 'Problem again' }, + }; + cb(null, { entities: req.namespaces?.[0] === 'my-ns' ? [strayDomain, parent] : [], nextPageToken: '' }); + }; + + const res = await request(app).get('/api/event-model/my-ns/stray-model'); + + fakeClient.getEntity = originalGetEntity; + fakeClient.listEntities = originalListEntities; + + expect(res.status).toBe(200); + expect(listed.some((r) => r.namespaces?.[0] === 'my-ns' && r.kinds?.join() === '17,18')).toBe(true); + const domains = res.body.entities.filter((e) => e.domain).map((e) => e.domain.title); + expect(domains.sort()).toEqual(['Problem', 'Stray domain']); + expect(res.body.entities.some((e) => e.project?.id?.slug === 'acme')).toBe(true); + }); + + it('keeps the rest of the knowledge graph when listing the namespace fails', async () => { + const originalGetEntity = fakeClient.getEntity; + const originalListEntities = fakeClient.listEntities; + fakeClient.getEntity = (req, _meta, _opts, cb) => { + const { kind, id } = req; + if (kind === 12) { + cb(null, { entity: { eventModel: { id, title: 'Model', members: [] } } }); + return; + } + if (kind === 16) { + cb(null, { entity: { boundedContext: { id, title: 'Context' } } }); + return; + } + cb(null, { entity: null }); + }; + fakeClient.listEntities = (_req, _meta, _opts, cb) => cb(new Error('listing exploded')); + + const res = await request(app).get('/api/event-model/my-ns/list-fails'); + + fakeClient.getEntity = originalGetEntity; + fakeClient.listEntities = originalListEntities; + + expect(res.status).toBe(200); + expect(res.body.entities.map((e) => Object.keys(e)[0]).sort()).toEqual(['boundedContext', 'eventModel']); + }); + it('tolerates a member fetch failure by dropping that member rather than failing the request', async () => { const originalGetEntity = fakeClient.getEntity; fakeClient.getEntity = (req, _meta, _opts, cb) => { diff --git a/tsworkspace/webapps/studio/src/components/OverviewPage.test.tsx b/tsworkspace/webapps/studio/src/components/OverviewPage.test.tsx index d593ff9e..2cf723f3 100644 --- a/tsworkspace/webapps/studio/src/components/OverviewPage.test.tsx +++ b/tsworkspace/webapps/studio/src/components/OverviewPage.test.tsx @@ -6,16 +6,27 @@ vi.mock('@/lib/api', () => ({ overview: vi.fn(), search: vi.fn(), namespaceRegistry: vi.fn(), + validateProject: vi.fn(), }, })); -vi.mock('@/components/canvas/DomainChartBoard', () => ({ - DomainChartBoard: ({ onSelect }: { onSelect: (key: string) => void }) => ( - - ), -})); +vi.mock('@/components/canvas/DomainChartBoard', async () => { + const { useIssues } = await import('@/components/IssuesProvider'); + return { + DomainChartBoard: ({ onSelect }: { onSelect: (key: string) => void }) => ( + + ), + }; +}); vi.mock('@/components/canvas/ContextMapBoard', () => ({ ContextMapBoard: () =>
, })); @@ -73,6 +84,7 @@ describe('OverviewPage', () => { (api.overview as ReturnType).mockResolvedValue(EMPTY_OVERVIEW); (api.search as ReturnType).mockResolvedValue({ results: [] }); (api.namespaceRegistry as ReturnType).mockResolvedValue({ namespaces: [] }); + (api.validateProject as ReturnType).mockResolvedValue({ reports: [] }); }); afterEach(() => { @@ -417,4 +429,74 @@ describe('OverviewPage', () => { fireEvent.click(screen.getByTestId('domain-chart-board')); expect(screen.getByText('The trading charter.')).toBeDefined(); }); + + describe('domain chart findings', () => { + const PROJECT_OVERVIEW = { + entities: [ + { + domain: { + id: { namespace: 'market', slug: 'trading', version: 1 }, + title: 'Trading', + project: { id: { namespace: 'acme', slug: 'acme', version: 1 } }, + }, + }, + { domain: { id: { namespace: 'market', slug: 'risk', version: 1 }, title: 'Risk' } }, + ], + }; + const STRATEGIC_REPORT = { + reports: [ + { + issues: [ + { + severity: 'SEVERITY_WARNING', + code: 'DOMAIN_IN_CONTEXT_NAMESPACE', + message: 'misplaced', + subject: { kind: 'ENTITY_KIND_DOMAIN', id: { namespace: 'market', slug: 'trading', version: '1' } }, + }, + ], + }, + ], + }; + + async function openChart() { + render(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + fireEvent.click(screen.getByRole('tab', { name: 'Domain chart' })); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + it('validates each project once, each domain without a project by itself and by namespace, on the current branch', async () => { + window.history.replaceState(null, '', '/?branch=feature-x'); + (api.overview as ReturnType).mockResolvedValue(PROJECT_OVERVIEW); + await openChart(); + + const calls = (api.validateProject as ReturnType).mock.calls; + expect(calls.map(([scope]) => scope)).toEqual([ + { projectNamespace: 'acme', projectSlug: 'acme', projectVersion: '1' }, + { domainNamespace: 'market', domainSlug: 'risk', domainVersion: '1' }, + { namespace: 'market' }, + ]); + for (const [, opts] of calls) expect(opts).toEqual(expect.objectContaining({ branch: 'feature-x' })); + }); + + it('hands the server findings to the chart', async () => { + (api.overview as ReturnType).mockResolvedValue(PROJECT_OVERVIEW); + (api.validateProject as ReturnType).mockResolvedValue(STRATEGIC_REPORT); + await openChart(); + expect(screen.getByTestId('domain-chart-board').getAttribute('data-findings')).toBe( + 'DOMAIN_IN_CONTEXT_NAMESPACE', + ); + }); + + it('renders the chart without findings when validation fails', async () => { + (api.overview as ReturnType).mockResolvedValue(PROJECT_OVERVIEW); + (api.validateProject as ReturnType).mockRejectedValue(new Error('unavailable')); + await openChart(); + expect(screen.getByTestId('domain-chart-board').getAttribute('data-findings')).toBe(''); + }); + }); }); diff --git a/tsworkspace/webapps/studio/src/components/OverviewPage.tsx b/tsworkspace/webapps/studio/src/components/OverviewPage.tsx index d7e9a4c4..1d645b25 100644 --- a/tsworkspace/webapps/studio/src/components/OverviewPage.tsx +++ b/tsworkspace/webapps/studio/src/components/OverviewPage.tsx @@ -14,11 +14,14 @@ import type { Selection } from '@/components/canvas/Board'; import { ContextMapBoard } from '@/components/canvas/ContextMapBoard'; import { DomainChartBoard } from '@/components/canvas/DomainChartBoard'; import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { IssuesProvider } from '@/components/IssuesProvider'; import { buttonVariants } from '@/components/ui/button'; import { EmptyState } from '@/components/ui/empty-state'; import { Tabs, TabsList, TabsTab } from '@/components/ui/tabs'; import { api, type NamespaceRegistryRow } from '@/lib/api'; import { readBranchFromUrl, withBranchQuery } from '@/lib/branch'; +import { validationScopes } from '@/lib/domainchart'; +import { buildProjectIssueIndex, EMPTY_ISSUE_INDEX, type IssueIndex } from '@/lib/issues'; import { asId, buildModel, @@ -139,6 +142,7 @@ export function OverviewPage() { const [searching, setSearching] = useState(false); const [searchResults, setSearchResults] = useState([]); const [chartFocusKey, setChartFocusKey] = useState(); + const [chartIssues, setChartIssues] = useState(EMPTY_ISSUE_INDEX); const [selectedDomains, setSelectedDomains] = useState(() => { const fromUrl = readUrlDomains(); if (fromUrl.length > 0) return fromUrl; @@ -190,6 +194,26 @@ export function OverviewPage() { // oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally mount-only, see above }, []); + // The chart renders without findings until validation answers, and stays + // without them when it fails: a marker is only ever the server's word. + const chartOpen = mode === 'chart'; + useEffect(() => { + if (!chart || !chartOpen) return; + const scopes = validationScopes(chart); + if (scopes.length === 0) return; + const ctrl = new AbortController(); + Promise.allSettled(scopes.map((scope) => api.validateProject(scope, { signal: ctrl.signal, branch }))).then( + (results) => { + if (ctrl.signal.aborted) return; + setChartIssues( + buildProjectIssueIndex(results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))), + ); + }, + ); + return () => ctrl.abort(); + // oxlint-disable-next-line react-hooks/exhaustive-deps -- branch is mount-only, see above + }, [chart, chartOpen]); + // Owners only shape the grouping, so a failed registry read keeps the // page usable with plain namespace groups. useEffect(() => { @@ -550,11 +574,13 @@ export function OverviewPage() { {visibleChart ? ( - setChartFocusKey(key)} - /> + + setChartFocusKey(key)} + /> + ) : ( diff --git a/tsworkspace/webapps/studio/src/components/canvas/DomainChartBoard.tsx b/tsworkspace/webapps/studio/src/components/canvas/DomainChartBoard.tsx index ee5404da..6ca8f224 100644 --- a/tsworkspace/webapps/studio/src/components/canvas/DomainChartBoard.tsx +++ b/tsworkspace/webapps/studio/src/components/canvas/DomainChartBoard.tsx @@ -8,18 +8,22 @@ import { Position, ReactFlow, } from '@xyflow/react'; -import { Globe, Waypoints } from 'lucide-react'; +import { CircleAlert, Globe, Info, TriangleAlert, Waypoints } from 'lucide-react'; import { useMemo } from 'react'; import type { Selection } from '@/components/canvas/Board'; +import { useIssues } from '@/components/IssuesProvider'; import { BoardEmptyState } from '@/components/ui/empty-state'; import { type BandData, buildDomainChart, CARD_H, CARD_W, + type ChartMarker, type ContextCardData, CTX_H, CTX_W, + chartMarkers, + markerTooltip, type SubdomainCardData, } from '@/lib/domainchart'; import type { Entity, Model } from '@/lib/model'; @@ -36,6 +40,28 @@ const TONE = { unclassified: { band: 'border-dashed border-zinc-300 bg-zinc-50/40', label: 'text-zinc-500' }, } as const; +const MARKER_STYLE = { + error: { icon: CircleAlert, tone: 'bg-red-100 text-red-700' }, + warning: { icon: TriangleAlert, tone: 'bg-amber-100 text-amber-800' }, + info: { icon: Info, tone: 'bg-sky-100 text-sky-700' }, +} as const; + +function FindingMarker({ marker }: { marker?: ChartMarker }) { + if (!marker) return null; + const { icon: Icon, tone } = MARKER_STYLE[marker.severity]; + const count = marker.findings.length; + return ( + f.code).join(', ')}`} + className={cn('ml-auto flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-bold', tone)} + > + + ); +} + function ClassificationBandNode({ data }: NodeProps & { data: BandData }) { const tone = TONE[data.tone]; return ( @@ -46,7 +72,9 @@ function ClassificationBandNode({ data }: NodeProps & { data: BandData }) { ); } -function DomainHeaderNode({ data }: NodeProps & { data: { entity: Entity; selected?: boolean } }) { +function DomainHeaderNode({ + data, +}: NodeProps & { data: { entity: Entity; marker?: ChartMarker; selected?: boolean } }) { return (
{data.entity.title} {data.entity.doc} +
); } @@ -73,10 +102,12 @@ function SubdomainCardNode({ data }: NodeProps & { data: SubdomainCardData }) {
{data.entity.title} + {data.classification ? ( o.title).join(', '); return (
-
{data.entity.title}
-
{data.entity.id.namespace}
+
+ {data.entity.title} + +
+
{data.entity.id.namespace}
+ {data.splitCandidate ? ( +
+ {data.splitCandidate} +
+ ) : others ? ( +
+ also realizes {others} +
+ ) : null}
); } @@ -127,7 +174,9 @@ export function DomainChartBoard({ onSelect: (key: string | undefined, occId?: string, additive?: boolean) => void; focus?: FocusRequest; }) { - const { nodes, edges } = useMemo(() => buildDomainChart(model), [model]); + const issues = useIssues(); + const markers = useMemo(() => chartMarkers(issues.all), [issues]); + const { nodes, edges } = useMemo(() => buildDomainChart(model, markers), [model, markers]); useFocusNode(focus, nodes, 'subdomainCard'); const displayNodes = useMemo(() => { @@ -181,11 +230,15 @@ export function DomainChartBoard({ take care of each one. ) : null} -
+
where investment goes: subdomains by classification, contexts realizing them
+
+ a context + realizing several subdomains appears under each one +
); diff --git a/tsworkspace/webapps/studio/src/lib/api.test.ts b/tsworkspace/webapps/studio/src/lib/api.test.ts index 17eb46c2..c226eec4 100644 --- a/tsworkspace/webapps/studio/src/lib/api.test.ts +++ b/tsworkspace/webapps/studio/src/lib/api.test.ts @@ -443,6 +443,15 @@ describe('BUG: validate / validate-project client surface', () => { expect(url).toContain('branch=alex%2Frework'); }); + it('api.validateProject encodes a namespace scope', async () => { + fetchSpy.mockResolvedValueOnce(makeOkResponse({ reports: [] })); + await api.validateProject({ namespace: 'accounts' }); + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/api/validate-project?'); + expect(url).toContain('namespace=accounts'); + expect(url).not.toContain('projectNamespace'); + }); + it('offers no bulk deletion, since agents own every edit', () => { expect(api).not.toHaveProperty('deleteByQuery'); }); diff --git a/tsworkspace/webapps/studio/src/lib/api.ts b/tsworkspace/webapps/studio/src/lib/api.ts index 18a501e8..6b22baad 100644 --- a/tsworkspace/webapps/studio/src/lib/api.ts +++ b/tsworkspace/webapps/studio/src/lib/api.ts @@ -287,6 +287,8 @@ export interface ValidateProjectScope { domainNamespace?: string; domainSlug?: string; domainVersion?: string; + /** Every EventModel in this namespace, whether or not a Domain claims it. */ + namespace?: string; } function buildQuery(params: Record): string { @@ -426,6 +428,7 @@ export const api = { domainNamespace: scope.domainNamespace, domainSlug: scope.domainSlug, domainVersion: scope.domainVersion, + namespace: scope.namespace, })}`, opts, ), diff --git a/tsworkspace/webapps/studio/src/lib/domainchart.test.ts b/tsworkspace/webapps/studio/src/lib/domainchart.test.ts index 4057bdb8..b497a049 100644 --- a/tsworkspace/webapps/studio/src/lib/domainchart.test.ts +++ b/tsworkspace/webapps/studio/src/lib/domainchart.test.ts @@ -4,7 +4,17 @@ // the subdomains they `realize`, and the "Unmapped" band only fires // when there is something to flag. import { describe, expect, it } from 'vitest'; -import { buildDomainChart } from './domainchart'; +import { + buildDomainChart, + type ChartMarker, + type ContextCardData, + chartMarkers, + markerTooltip, + SPLIT_CANDIDATE_CODE, + splitCandidateText, + validationScopes, +} from './domainchart'; +import type { Issue } from './issues'; import { buildModel } from './model'; const id = (namespace: string, slug: string): Record => ({ @@ -117,4 +127,212 @@ describe('buildDomainChart', () => { expect(right).toBeLessThanOrEqual(bandW); } }); + + describe('contexts realizing several subdomains', () => { + const sharedEntities = (secondClassification: string): Record[] => [ + ...fixtureEntities(), + { + subdomain: { + id: id('shop', 'payments'), + title: 'payments', + domain: { id: id('shop', 'shop') }, + classification: secondClassification, + }, + }, + { + boundedContext: { + id: id('storefront', 'storefront'), + title: 'storefront', + realizes: [{ id: id('shop', 'identity') }, { id: id('shop', 'payments') }], + }, + }, + ]; + + const copiesOf = (nodes: { id: string; data: unknown }[], title: string) => + nodes.filter((n) => n.id.startsWith('ctx:') && (n.data as ContextCardData).entity.title === title); + + const mixedFinding = (slug: string): Issue => ({ + severity: 'warning', + code: SPLIT_CANDIDATE_CODE, + message: 'realizes subdomains of different classifications', + field: 'realizes', + ruleTitle: '', + ruleCategory: 'strategic', + subject: { kind: 'boundedContext', id: { namespace: slug, slug, version: '1' } }, + }); + + it('lists the other subdomains on each copy', () => { + const { nodes } = buildDomainChart(buildModel(sharedEntities('SUPPORTING') as never)); + const copies = copiesOf(nodes, 'storefront').map((n) => n.data as ContextCardData); + expect(copies.length).toBe(2); + const underCore = copies.find((c) => c.classification === 'core'); + const underSupporting = copies.find((c) => c.classification === 'supporting'); + expect(underCore?.alsoRealizes.map((o) => [o.title, o.classification])).toEqual([['payments', 'supporting']]); + expect(underSupporting?.alsoRealizes.map((o) => [o.title, o.classification])).toEqual([['identity', 'core']]); + }); + + it('does not decide a split candidate without the server finding', () => { + const { nodes } = buildDomainChart(buildModel(sharedEntities('SUPPORTING') as never)); + for (const copy of copiesOf(nodes, 'storefront')) { + expect((copy.data as ContextCardData).splitCandidate).toBeUndefined(); + } + }); + + it('flags a split candidate on every copy when the server reports mixed classifications', () => { + const markers = chartMarkers([mixedFinding('storefront')]); + const { nodes } = buildDomainChart(buildModel(sharedEntities('SUPPORTING') as never), markers); + const copies = copiesOf(nodes, 'storefront').map((n) => n.data as ContextCardData); + expect(copies.map((c) => c.splitCandidate)).toEqual([ + 'split candidate: core and supporting', + 'split candidate: core and supporting', + ]); + expect(copies.every((c) => c.marker?.findings[0]?.code === SPLIT_CANDIDATE_CODE)).toBe(true); + }); + + it('shows a marker without a split chip when the realized subdomains share a band', () => { + const markers = chartMarkers([mixedFinding('storefront')]); + const { nodes } = buildDomainChart(buildModel(sharedEntities('CORE') as never), markers); + for (const copy of copiesOf(nodes, 'storefront')) { + const data = copy.data as ContextCardData; + expect(data.alsoRealizes.length).toBe(1); + expect(data.splitCandidate).toBeUndefined(); + expect(data.marker).toBeDefined(); + } + }); + + it('leaves a single-subdomain context neither shared nor spanning', () => { + const { nodes } = buildDomainChart(buildModel(fixtureEntities() as never)); + const copies = copiesOf(nodes, 'accounts'); + expect(copies.length).toBe(1); + const data = copies[0].data as ContextCardData; + expect(data.alsoRealizes).toEqual([]); + expect(data.splitCandidate).toBeUndefined(); + }); + + it('gives unmapped contexts no other subdomains', () => { + const entities = [...fixtureEntities(), { boundedContext: { id: id('orphan', 'orphan'), title: 'orphan' } }]; + const { nodes } = buildDomainChart(buildModel(entities as never)); + const data = nodes.find((n) => n.id.startsWith('ctx:unmapped:'))?.data as ContextCardData; + expect(data.alsoRealizes).toEqual([]); + expect(data.splitCandidate).toBeUndefined(); + }); + }); + + describe('splitCandidateText', () => { + it('names the two spanned bands', () => { + expect(splitCandidateText(['core', 'generic'])).toBe('split candidate: core and generic'); + }); + + it('lists three spanned bands in order', () => { + expect(splitCandidateText(['core', 'supporting', 'generic'])).toBe( + 'split candidate: core, supporting and generic', + ); + }); + }); +}); + +function strategicIssue(overrides: Partial = {}): Issue { + return { + severity: 'warning', + code: 'DOMAIN_IN_CONTEXT_NAMESPACE', + message: 'Domain declared in a context namespace.', + field: '', + ruleTitle: '', + ruleCategory: 'strategic', + subject: { kind: 'domain', id: { namespace: 'accounts', slug: 'accounts', version: '1' } }, + ...overrides, + }; +} + +describe('chartMarkers', () => { + it('groups findings by entity, worst first, and reports the worst severity', () => { + const markers = chartMarkers([ + strategicIssue({ severity: 'info', code: 'B_INFO' }), + strategicIssue({ severity: 'error', code: 'A_ERROR' }), + strategicIssue({ severity: 'warning', code: 'A_WARN' }), + ]); + const marker = markers.get('domain:accounts/accounts@1') as ChartMarker; + expect(marker.severity).toBe('error'); + expect(marker.findings.map((f) => f.code)).toEqual(['A_ERROR', 'A_WARN', 'B_INFO']); + expect(markerTooltip(marker)).toBe( + 'A_ERROR: Domain declared in a context namespace.\nA_WARN: Domain declared in a context namespace.\nB_INFO: Domain declared in a context namespace.', + ); + }); + + it('ignores findings without a subject or on kinds the chart does not draw', () => { + const markers = chartMarkers([ + strategicIssue({ subject: undefined }), + strategicIssue({ subject: { kind: 'event', id: { namespace: 'shop', slug: 'placed', version: '1' } } }), + ]); + expect(markers.size).toBe(0); + }); + + it('puts the marker on the domain header and the subdomain card', () => { + const markers = chartMarkers([ + strategicIssue({ subject: { kind: 'domain', id: { namespace: 'shop', slug: 'shop', version: '1' } } }), + strategicIssue({ + code: 'SUBDOMAIN_UNREALIZED', + subject: { kind: 'subdomain', id: { namespace: 'shop', slug: 'billing', version: '1' } }, + }), + ]); + const { nodes } = buildDomainChart(buildModel(fixtureEntities() as never), markers); + const header = nodes.find((n) => n.type === 'domainHeader')?.data as { marker?: ChartMarker }; + const billing = nodes.find((n) => n.id.startsWith('sd:') && n.id.includes('billing'))?.data as { + marker?: ChartMarker; + }; + expect(header.marker?.findings[0].code).toBe('DOMAIN_IN_CONTEXT_NAMESPACE'); + expect(billing.marker?.findings[0].code).toBe('SUBDOMAIN_UNREALIZED'); + }); +}); + +describe('validationScopes', () => { + it('scopes by the project a domain declares and by the domain when it declares none', () => { + const model = buildModel([ + { domain: { id: id('shop', 'shop'), project: { id: id('acme', 'acme') } } }, + { domain: { id: id('ops', 'ops'), project: { id: id('acme', 'acme') } } }, + { domain: { id: id('legacy', 'legacy') } }, + ] as never); + expect(validationScopes(model)).toEqual([ + { projectNamespace: 'acme', projectSlug: 'acme', projectVersion: '1' }, + { domainNamespace: 'legacy', domainSlug: 'legacy', domainVersion: '1' }, + { namespace: 'legacy' }, + ]); + }); + + it('has no scopes without domains or contexts', () => { + expect(validationScopes(buildModel([] as never))).toEqual([]); + }); + + it('adds no namespace scope for a context whose subdomain reaches a loaded domain with a project', () => { + const model = buildModel([ + { domain: { id: id('shop', 'shop'), project: { id: id('acme', 'acme') } } }, + { subdomain: { id: id('shop', 'identity'), domain: { id: id('shop', 'shop') } } }, + { boundedContext: { id: id('accounts', 'accounts'), realizes: [{ id: id('shop', 'identity') }] } }, + ] as never); + expect(validationScopes(model)).toEqual([{ projectNamespace: 'acme', projectSlug: 'acme', projectVersion: '1' }]); + }); + + it('adds a namespace scope for contexts that reach no domain, once per namespace', () => { + const model = buildModel([ + { domain: { id: id('shop', 'shop'), project: { id: id('acme', 'acme') } } }, + { subdomain: { id: id('shop', 'identity'), domain: { id: id('shop', 'shop') } } }, + { boundedContext: { id: id('orphan', 'orphan') } }, + { boundedContext: { id: id('dangling', 'dangling'), realizes: [{ id: id('shop', 'missing') }] } }, + { boundedContext: { id: id('dangling', 'dangling-two') } }, + { boundedContext: { id: id('accounts', 'accounts'), realizes: [{ id: id('shop', 'identity') }] } }, + ] as never); + expect(validationScopes(model)).toEqual([ + { projectNamespace: 'acme', projectSlug: 'acme', projectVersion: '1' }, + { namespace: 'orphan' }, + { namespace: 'dangling' }, + ]); + }); + + it('does not repeat a namespace scope already added for a project-less domain', () => { + const model = buildModel([ + { domain: { id: id('legacy', 'legacy') } }, + { boundedContext: { id: id('legacy', 'legacy') } }, + ] as never); + expect(validationScopes(model).filter((s) => s.namespace)).toEqual([{ namespace: 'legacy' }]); + }); }); diff --git a/tsworkspace/webapps/studio/src/lib/domainchart.ts b/tsworkspace/webapps/studio/src/lib/domainchart.ts index b55c0759..92b443b1 100644 --- a/tsworkspace/webapps/studio/src/lib/domainchart.ts +++ b/tsworkspace/webapps/studio/src/lib/domainchart.ts @@ -4,12 +4,23 @@ // bought, and each subdomain shows the bounded contexts realizing it. // Everything derives from Domain/Subdomain/realizes; no new data. import type { Edge, Node } from '@xyflow/react'; -import { classificationOf, type Entity, type Model, realizesOf } from './model'; +import type { ValidateProjectScope } from './api'; +import type { Issue, Severity } from './issues'; +import { + classificationOf, + domainOf, + type Entity, + type EntityId, + type EntityKind, + entityKey, + type Model, + realizesOf, +} from './model'; export const CARD_W = 240; export const CARD_H = 96; -export const CTX_W = 200; -export const CTX_H = 56; +export const CTX_W = 240; +export const CTX_H = 68; const BAND_PAD = 40; const BAND_HEADER = 48; const COL_GAP = 48; @@ -20,14 +31,118 @@ const CTX_GAP = 24; // widened so the "realizes" arrows have room to breathe. const SD_TO_CTX_GAP = 56; +export const SPLIT_CANDIDATE_CODE = 'REALIZES_MIXED_CLASSIFICATIONS'; + +const CHART_SUBJECT_KINDS: ReadonlySet = new Set(['boundedContext', 'domain', 'subdomain']); +const SEVERITY_ORDER: Record = { error: 0, warning: 1, info: 2 }; + +export interface ChartFinding { + severity: Severity; + code: string; + message: string; +} + +export interface ChartMarker { + /** The worst severity among the findings. */ + severity: Severity; + /** Worst first. */ + findings: ChartFinding[]; +} + +/** Server findings per chart entity, keyed by `Entity.key`. */ +export type ChartMarkers = ReadonlyMap; + +export const NO_MARKERS: ChartMarkers = new Map(); + +/** Keeps the findings whose subject is a context, domain or subdomain and groups them by entity. */ +export function chartMarkers(issues: readonly Issue[]): ChartMarkers { + const grouped = new Map(); + for (const issue of issues) { + if (!issue.subject || !CHART_SUBJECT_KINDS.has(issue.subject.kind)) continue; + const key = entityKey(issue.subject.kind, issue.subject.id); + const bucket = grouped.get(key) ?? []; + bucket.push({ severity: issue.severity, code: issue.code, message: issue.message }); + grouped.set(key, bucket); + } + const markers = new Map(); + for (const [key, findings] of grouped) { + findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.code.localeCompare(b.code)); + markers.set(key, { severity: findings[0].severity, findings }); + } + return markers; +} + +export function markerTooltip(marker: ChartMarker): string { + return marker.findings.map((f) => `${f.code}: ${f.message}`).join('\n'); +} + +/** + * The validation scopes that cover the chart. Each domain is covered by the + * Project it declares, otherwise by itself. A namespace scope is added for a + * problem-space namespace whose Domain declares no Project, and for each + * context whose realized subdomains reach no Domain in the model, so that + * strategic findings on them are still reported. Each scope appears once. + */ +export function validationScopes(model: Model): ValidateProjectScope[] { + const covering = new Map(); + const namespaces = new Map(); + const domainKeys = new Set(); + for (const domain of model.entities) { + if (domain.kind !== 'domain') continue; + domainKeys.add(`${domain.id.namespace}/${domain.id.slug}`); + const project = ( + domain.raw.project as { id?: { namespace?: string; slug?: string; version?: unknown } } | undefined + )?.id; + if (project?.namespace && project.slug) { + covering.set(`project:${project.namespace}/${project.slug}`, { + projectNamespace: project.namespace, + projectSlug: project.slug, + projectVersion: project.version ? String(project.version) : undefined, + }); + } else { + covering.set(`domain:${domain.id.namespace}/${domain.id.slug}`, { + domainNamespace: domain.id.namespace, + domainSlug: domain.id.slug, + domainVersion: domain.id.version, + }); + namespaces.set(domain.id.namespace, { namespace: domain.id.namespace }); + } + } + const subdomainDomain = new Map(); + for (const sd of model.entities) { + if (sd.kind === 'subdomain') subdomainDomain.set(`${sd.id.namespace}/${sd.id.slug}`, domainOf(sd)); + } + for (const bc of model.entities) { + if (bc.kind !== 'boundedContext') continue; + const reachesDomain = realizesOf(bc).some((rid) => { + const parent = subdomainDomain.get(`${rid.namespace}/${rid.slug}`); + return parent ? domainKeys.has(`${parent.namespace}/${parent.slug}`) : false; + }); + if (!reachesDomain) namespaces.set(bc.id.namespace, { namespace: bc.id.namespace }); + } + return [...covering.values(), ...namespaces.values()]; +} + export interface SubdomainCardData extends Record { entity: Entity; classification?: string; + marker?: ChartMarker; selected?: boolean; } +export interface SharedSubdomain { + entity: Entity; + title: string; + classification: string; +} + export interface ContextCardData extends Record { entity: Entity; + classification?: string; + alsoRealizes: SharedSubdomain[]; + /** Set only when the server reports REALIZES_MIXED_CLASSIFICATIONS for this context. */ + splitCandidate?: string; + marker?: ChartMarker; selected?: boolean; } @@ -44,7 +159,12 @@ const BANDS: { key: string; label: string; hint: string; tone: BandData['tone'] { key: 'unclassified', label: 'Unclassified', hint: 'classify it: this is a budget document', tone: 'unclassified' }, ]; -export function buildDomainChart(model: Model): { nodes: Node[]; edges: Edge[] } { +export function splitCandidateText(bands: string[]): string { + const names = bands.length > 1 ? `${bands.slice(0, -1).join(', ')} and ${bands[bands.length - 1]}` : bands.join(''); + return `split candidate: ${names}`; +} + +export function buildDomainChart(model: Model, markers: ChartMarkers = NO_MARKERS): { nodes: Node[]; edges: Edge[] } { const subdomains = model.entities.filter((e) => e.kind === 'subdomain'); const contexts = model.entities.filter((e) => e.kind === 'boundedContext'); const domains = model.entities.filter((e) => e.kind === 'domain'); @@ -61,6 +181,28 @@ export function buildDomainChart(model: Model): { nodes: Node[]; edges: Edge[] } } } + const subdomainByKey = new Map(subdomains.map((sd) => [`${sd.id.namespace}/${sd.id.slug}`, sd])); + const bandOf = (sd: Entity) => classificationOf(sd) ?? 'unclassified'; + const contextCardData = (bc: Entity, home?: Entity): ContextCardData => { + const alsoRealizes: SharedSubdomain[] = []; + for (const rid of realizesOf(bc)) { + const other = subdomainByKey.get(`${rid.namespace}/${rid.slug}`); + if (!home || !other || other.key === home.key) continue; + alsoRealizes.push({ entity: other, title: other.title, classification: bandOf(other) }); + } + const marker = markers.get(bc.key); + const touched = new Set([...(home ? [bandOf(home)] : []), ...alsoRealizes.map((o) => o.classification)]); + const bands = BANDS.map((b) => b.key).filter((k) => touched.has(k)); + const reported = marker?.findings.some((f) => f.code === SPLIT_CANDIDATE_CODE) ?? false; + return { + entity: bc, + classification: home ? bandOf(home) : undefined, + alsoRealizes, + splitCandidate: reported && bands.length > 1 ? splitCandidateText(bands) : undefined, + marker, + }; + }; + const nodes: Node[] = []; const edges: Edge[] = []; // Pre-compute each band's width so the domain header spans the widest. @@ -90,7 +232,7 @@ export function buildDomainChart(model: Model): { nodes: Node[]; edges: Edge[] } type: 'domainHeader', position: { x: 0, y }, style: { width: widestBand, height: 44 }, - data: { entity: d }, + data: { entity: d, marker: markers.get(d.key) }, draggable: false, zIndex: 2, }); @@ -135,7 +277,11 @@ export function buildDomainChart(model: Model): { nodes: Node[]; edges: Edge[] } type: 'subdomainCard', position: { x: sdX, y: sdY }, style: { width: CARD_W, height: CARD_H }, - data: { entity: sd, classification: classificationOf(sd) } satisfies SubdomainCardData, + data: { + entity: sd, + classification: classificationOf(sd), + marker: markers.get(sd.key), + } satisfies SubdomainCardData, draggable: false, zIndex: 2, }); @@ -148,7 +294,7 @@ export function buildDomainChart(model: Model): { nodes: Node[]; edges: Edge[] } type: 'contextCard', position: { x: rowStart + j * (CTX_W + CTX_GAP), y: sdY + CARD_H + SD_TO_CTX_GAP }, style: { width: CTX_W, height: CTX_H }, - data: { entity: bc } satisfies ContextCardData, + data: contextCardData(bc, sd), draggable: false, zIndex: 2, }); @@ -201,7 +347,7 @@ export function buildDomainChart(model: Model): { nodes: Node[]; edges: Edge[] } type: 'contextCard', position: { x: BAND_PAD + i * (CTX_W + COL_GAP), y: y + BAND_HEADER + BAND_PAD }, style: { width: CTX_W, height: CTX_H }, - data: { entity: bc } satisfies ContextCardData, + data: contextCardData(bc), draggable: false, zIndex: 2, }); diff --git a/tsworkspace/webapps/studio/src/lib/issues.test.ts b/tsworkspace/webapps/studio/src/lib/issues.test.ts index 3778fec6..203e1970 100644 --- a/tsworkspace/webapps/studio/src/lib/issues.test.ts +++ b/tsworkspace/webapps/studio/src/lib/issues.test.ts @@ -2,7 +2,7 @@ // ValidateEventModelResponse.issues (service.proto: ValidationIssue). import { describe, expect, it } from 'vitest'; -import { buildIssueIndex, EMPTY_ISSUE_INDEX } from './issues'; +import { buildIssueIndex, buildProjectIssueIndex, EMPTY_ISSUE_INDEX } from './issues'; // Shaped like GET /api/validate?namespace=registry&slug=changeset-review-flow. const RM_EVENT_NOT_PROJECTED = { @@ -88,3 +88,33 @@ describe('buildIssueIndex', () => { expect(EMPTY_ISSUE_INDEX.error).toBeUndefined(); }); }); + +describe('buildProjectIssueIndex', () => { + const strategic = { + severity: 'SEVERITY_WARNING', + code: 'DOMAIN_IN_CONTEXT_NAMESPACE', + message: 'misplaced', + subject: { kind: 'ENTITY_KIND_DOMAIN', id: { namespace: 'accounts', slug: 'accounts', version: '1' } }, + }; + const own = { severity: 'SEVERITY_ERROR', code: 'DANGLING_REF', message: 'missing' }; + + it('collapses the strategic chain repeated by the store-level and per-model reports', () => { + const index = buildProjectIssueIndex([ + { + reports: [ + { issues: [strategic] }, + { eventModelId: { namespace: 'accounts', slug: 'checkout' }, issues: [strategic, own] }, + ], + }, + { reports: [{ issues: [strategic] }] }, + ]); + expect(index.all.map((i) => i.code)).toEqual(['DANGLING_REF', 'DOMAIN_IN_CONTEXT_NAMESPACE']); + expect(index.for({ kind: 'domain', id: { namespace: 'accounts', slug: 'accounts', version: '1' } })).toHaveLength( + 1, + ); + }); + + it('is empty for missing responses and reports', () => { + expect(buildProjectIssueIndex([undefined, {}, { reports: [{}] }]).all).toEqual([]); + }); +}); diff --git a/tsworkspace/webapps/studio/src/lib/issues.ts b/tsworkspace/webapps/studio/src/lib/issues.ts index a8edf539..a87fda01 100644 --- a/tsworkspace/webapps/studio/src/lib/issues.ts +++ b/tsworkspace/webapps/studio/src/lib/issues.ts @@ -111,5 +111,29 @@ export function buildIssueIndex(raw: unknown, error?: string): IssueIndex { }; } +/** + * The findings of ValidateProject responses as one index. The store-level + * report (empty event model id) carries the strategic chain once, and each + * model's report repeats the chain of its own namespace, so identical + * findings are collapsed, across responses too. + */ +export function buildProjectIssueIndex(responses: readonly ({ reports?: unknown[] } | undefined)[]): IssueIndex { + const seen = new Set(); + const raw: unknown[] = []; + for (const response of responses) { + for (const report of response?.reports ?? []) { + const issues = (report as { issues?: unknown } | null)?.issues; + if (!Array.isArray(issues)) continue; + for (const issue of issues) { + const key = JSON.stringify([issue?.code, issue?.severity, issue?.subject, issue?.subjectField, issue?.message]); + if (seen.has(key)) continue; + seen.add(key); + raw.push(issue); + } + } + } + return buildIssueIndex(raw); +} + /** Shared empty index: no findings, and no claim that validation ran. */ export const EMPTY_ISSUE_INDEX: IssueIndex = buildIssueIndex(undefined); diff --git a/tsworkspace/webapps/studio/src/lib/validation-context.test.ts b/tsworkspace/webapps/studio/src/lib/validation-context.test.ts index 38e5399a..dd8a16ae 100644 --- a/tsworkspace/webapps/studio/src/lib/validation-context.test.ts +++ b/tsworkspace/webapps/studio/src/lib/validation-context.test.ts @@ -492,3 +492,110 @@ it('copies Team/Person membership and all exact responsibility selectors without }[]; expect(definitions[0].raw.responsibilities.map((item) => item.subject)).toEqual(selectors); }); + +describe('validationFixPrompt for strategic findings', () => { + const sid = (namespace: string, slug: string) => id(slug, '1', namespace); + const strategicModel = () => + buildModel([ + { project: { id: sid('acme', 'acme'), title: 'Acme' } }, + { domain: { id: sid('acme', 'acme'), title: 'Acme', project: { id: sid('acme', 'acme') } } }, + { domain: { id: sid('accounts', 'accounts'), title: 'Stray', doc: 'Declared in a context namespace.' } }, + { subdomain: { id: sid('acme', 'identity'), domain: { id: sid('acme', 'acme') }, classification: 'CORE' } }, + { subdomain: { id: sid('acme', 'billing'), domain: { id: sid('acme', 'acme') }, classification: 'GENERIC' } }, + { boundedContext: { id: sid('accounts', 'accounts'), realizes: [{ id: sid('acme', 'identity') }] } }, + { boundedContext: { id: sid('payments', 'payments'), realizes: [{ id: sid('acme', 'identity') }] } }, + { boundedContext: { id: sid('unrelated', 'unrelated') } }, + ]); + const strategicFinding = (subject: NonNullable, code = 'DOMAIN_IN_CONTEXT_NAMESPACE') => + finding({ code, subject, ruleCategory: 'strategic', field: '' }); + const loaded = (prompt: string) => + (snapshot(prompt, 'Loaded subject and related definitions') as { identity: string }[]).map((d) => d.identity); + + it('swaps the temporal guidance for the strategic repair instructions', () => { + const prompt = validationFixPrompt( + strategicModel(), + strategicFinding({ kind: 'domain', id: sid('accounts', 'accounts') }), + ); + expect(prompt).toContain('whose slug equals the Domain'); + expect(prompt).toContain('never create a Domain per context namespace'); + expect(prompt).toContain('split the context along classification lines instead of reclassifying'); + expect(prompt).toContain('Studio is a review surface'); + expect(prompt).toContain('`validate_project`'); + expect(prompt).toContain('docs/how-to/repair-validation-findings.md'); + expect(prompt).toContain('One problem space, contexts realize across namespaces'); + expect(prompt).not.toContain('For temporal findings'); + expect(prompt).not.toContain('\u2014'); + }); + + it('keeps the temporal guidance for non-strategic findings', () => { + const prompt = validationFixPrompt(temporalModel(), finding()); + expect(prompt).toContain('For temporal findings'); + expect(prompt).not.toContain('whose slug equals the Domain'); + }); + + it('includes the contexts sharing a Domain namespace and the subdomains pointing at the Domain', () => { + const prompt = validationFixPrompt( + strategicModel(), + strategicFinding({ kind: 'domain', id: sid('accounts', 'accounts') }), + ); + expect(prompt).not.toContain('The subject is not loaded'); + expect(loaded(prompt)).toEqual( + expect.arrayContaining(['domain:accounts/accounts@1', 'boundedContext:accounts/accounts@1']), + ); + expect(loaded(prompt)).not.toContain('boundedContext:unrelated/unrelated@1'); + + const parent = validationFixPrompt(strategicModel(), strategicFinding({ kind: 'domain', id: sid('acme', 'acme') })); + expect(loaded(parent)).toEqual( + expect.arrayContaining([ + 'subdomain:acme/identity@1', + 'subdomain:acme/billing@1', + 'boundedContext:accounts/accounts@1', + 'boundedContext:payments/payments@1', + 'project:acme/acme@1', + ]), + ); + expect(loaded(parent)).not.toContain('boundedContext:unrelated/unrelated@1'); + }); + + it('includes the contexts realizing a subdomain', () => { + const prompt = validationFixPrompt( + strategicModel(), + strategicFinding({ kind: 'subdomain', id: sid('acme', 'identity') }, 'SUBDOMAIN_UNREALIZED'), + ); + expect(loaded(prompt)).toEqual( + expect.arrayContaining([ + 'subdomain:acme/identity@1', + 'boundedContext:accounts/accounts@1', + 'boundedContext:payments/payments@1', + 'domain:acme/acme@1', + ]), + ); + expect(loaded(prompt)).not.toContain('boundedContext:unrelated/unrelated@1'); + }); + + it('includes the realized subdomains, their domains, the stray domain and sibling contexts of a context', () => { + const prompt = validationFixPrompt( + strategicModel(), + strategicFinding({ kind: 'boundedContext', id: sid('accounts', 'accounts') }, 'REALIZES_MIXED_CLASSIFICATIONS'), + ); + expect(loaded(prompt)).toEqual( + expect.arrayContaining([ + 'boundedContext:accounts/accounts@1', + 'subdomain:acme/identity@1', + 'domain:acme/acme@1', + 'domain:accounts/accounts@1', + 'boundedContext:payments/payments@1', + ]), + ); + expect(loaded(prompt)).not.toContain('boundedContext:unrelated/unrelated@1'); + }); + + it('treats the project reference of a domain as a reference to fetch', () => { + const model = buildModel([{ domain: { id: sid('acme', 'acme'), project: { id: sid('acme', 'acme') } } }]); + const prompt = validationFixPrompt(model, strategicFinding({ kind: 'domain', id: sid('acme', 'acme') })); + expect(snapshot(prompt, 'References to fetch')).toEqual({ + notLoaded: ['project:acme/acme@1'], + loadedButOmitted: [], + }); + }); +}); diff --git a/tsworkspace/webapps/studio/src/lib/validation-context.ts b/tsworkspace/webapps/studio/src/lib/validation-context.ts index 83ce0da7..eeb1bd6a 100644 --- a/tsworkspace/webapps/studio/src/lib/validation-context.ts +++ b/tsworkspace/webapps/studio/src/lib/validation-context.ts @@ -1,5 +1,15 @@ import type { Issue } from '@/lib/issues'; -import { asId, type Entity, type EntityId, type EntityKind, entityKey, type Model, wireKind } from '@/lib/model'; +import { + asId, + domainOf, + type Entity, + type EntityId, + type EntityKind, + entityKey, + type Model, + realizesOf, + wireKind, +} from '@/lib/model'; type ContextRef = { kind: EntityKind | 'slice'; id: EntityId }; @@ -24,6 +34,7 @@ const referenceFields: Record = { upstream: 'boundedContext', realizes: 'subdomain', domain: 'domain', + project: 'project', specification: 'specification', schema: 'schema', channel: 'channel', @@ -108,19 +119,84 @@ const annotationInstructions = [ 'Ask the human for the answer or the confirmation, and its source. Then edit the annotation or remove it once resolved, rerun validation for the affected scope, and report the annotation changed and the validation results. If the human cannot settle it yet, leave the annotation in place.', ]; +const fetchAndPreserveInstruction = + 'Fetch referenced definitions that are missing or omitted, including their exact pinned versions. Apply a targeted repair that preserves intended behavior, event causality, and existing contracts. Do not merely suppress the rule, downgrade its severity, or delete valid behavior to hide the finding.'; + +const applyAndReportInstruction = + 'Apply the repair, rerun validation for the affected scope, and report the entities changed, the reason for the repair, and the validation results, including any remaining findings. If the model lacks enough information to choose a behavior-preserving repair, explain the specific ambiguity instead of inventing domain behavior.'; + const repairInstructions = [ 'For responsibility findings, preserve the complete typed selector, exact version, owner UID, member kind and ID, port direction and ID, occurrence ID and optional artifact digest. Fetch current authorized native content before repairing stale selections; never infer replacements or inherited ownership from membership.', - 'Fetch referenced definitions that are missing or omitted, including their exact pinned versions. Apply a targeted repair that preserves intended behavior, event causality, and existing contracts. Do not merely suppress the rule, downgrade its severity, or delete valid behavior to hide the finding.', + fetchAndPreserveInstruction, 'For temporal findings, inspect the full ordered storyboard, fetch the definitions of the slices and events it lists that bear on the finding, and check the source and emitted events before deciding whether to change ordering, references, or modeling. Do not assume an event missing from this snapshot does not exist.', - 'Apply the repair, rerun validation for the affected scope, and report the entities changed, the reason for the repair, and the validation results, including any remaining findings. If the model lacks enough information to choose a behavior-preserving repair, explain the specific ambiguity instead of inventing domain behavior.', + applyAndReportInstruction, +]; + +const strategicKinds: ReadonlySet = new Set(['boundedContext', 'domain', 'subdomain']); + +const strategicInstructions = [ + fetchAndPreserveInstruction, + 'This is a strategic finding about the problem space and the contexts that realize it. The problem space (Project, Domain and Subdomains) lives in one namespace whose slug equals the Domain. Each context namespace holds only its BoundedContext, which realizes a subdomain across namespaces. A Domain declared in a context namespace is misplaced: never create a Domain per context namespace, and do not move the problem space into a context.', + 'When a context realizes subdomains of different classifications, split the context along classification lines instead of reclassifying a subdomain to clear the finding. Use the loaded referrers (subdomains pointing at a Domain, contexts realizing a subdomain, contexts sharing the Domain namespace) to see what depends on the subject, and fetch the current definition of anything not loaded before choosing a repair.', + 'Make the change with the Atlas MCP tools or CLI, because Studio is a review surface and does not edit the model. Afterward run `validate_project` for the problem-space namespace or the project, since strategic findings are reported once at store level, and report the entities changed, the reason, and any remaining findings. See docs/how-to/repair-validation-findings.md and docs/explanation/validation-repair-patterns.md ("One problem space, contexts realize across namespaces").', + applyAndReportInstruction, ]; +const slugIdentity = (id: EntityId) => `${id.namespace}/${id.slug}`; + +// What points at a strategic subject, from the entities the model loaded: +// the outgoing references alone cannot show that a Domain has subdomains or +// that a context shares its namespace. +function strategicNeighbors(model: Model, subject: Entity): Entity[] { + const subdomains = model.byKind.get('subdomain') ?? []; + const contexts = model.byKind.get('boundedContext') ?? []; + const domains = model.byKind.get('domain') ?? []; + const realizedBy = (subdomain: Entity) => + contexts.filter((bc) => realizesOf(bc).some((id) => slugIdentity(id) === slugIdentity(subdomain.id))); + const inNamespace = (entities: Entity[]) => entities.filter((entity) => entity.id.namespace === subject.id.namespace); + + if (subject.kind === 'domain') { + const children = subdomains.filter((sd) => { + const parent = domainOf(sd); + return parent && slugIdentity(parent) === slugIdentity(subject.id); + }); + return [...children, ...children.flatMap(realizedBy), ...inNamespace(contexts)]; + } + if (subject.kind === 'subdomain') { + const parent = domainOf(subject); + return [ + ...realizedBy(subject), + ...domains.filter((d) => parent && slugIdentity(d.id) === slugIdentity(parent)), + ...subdomains.filter((sd) => { + const other = domainOf(sd); + return sd.key !== subject.key && parent && other && slugIdentity(other) === slugIdentity(parent); + }), + ]; + } + const realized = realizesOf(subject) + .map((id) => subdomains.find((sd) => slugIdentity(sd.id) === slugIdentity(id))) + .filter((sd): sd is Entity => Boolean(sd)); + const parents = realized.flatMap((sd) => { + const parent = domainOf(sd); + return parent ? domains.filter((d) => slugIdentity(d.id) === slugIdentity(parent)) : []; + }); + return [ + ...realized, + ...parents, + ...inNamespace(domains), + ...inNamespace(subdomains), + ...realized.flatMap(realizedBy).filter((bc) => bc.key !== subject.key), + ]; +} + export function validationFixPrompt( model: Model, issue: Issue, options: { branch?: string; momentKey?: string; scopePath?: string } = {}, ): string { const annotation = annotationCodes.has(issue.code); + const strategic = + Boolean(issue.subject && strategicKinds.has(issue.subject.kind)) || issue.ruleCategory === 'strategic'; const selected = new Map(); const referenced = new Map(); const refsByKey = new Map(model.entities.map((entity) => [entity.key, references(entity)])); @@ -182,6 +258,11 @@ export function validationFixPrompt( for (const entity of model.byKind.get('eventModel') ?? []) include(entity); } + const strategicSubject = issue.subject ? model.byKey.get(identity(issue.subject)) : undefined; + if (strategicSubject && strategicKinds.has(strategicSubject.kind)) { + for (const entity of strategicNeighbors(model, strategicSubject)) include(entity); + } + const missing = [...referenced.values()].filter((ref) => resolve(model, ref).length === 0).map(identity); const omitted = [...referenced.values()] .filter((ref) => resolve(model, ref).some((entity) => !selected.has(entity.key))) @@ -193,7 +274,7 @@ export function validationFixPrompt( return [ `${annotation ? 'Resolve' : 'Fix'} this validation finding in the Atlas project using the \`trogon-atlas\` CLI or the \`trogon-atlas-mcp\` MCP tools.`, 'Re-read the current model and validation results in the specified branch and scope before editing. The snapshot below may be stale or incomplete.', - ...(annotation ? annotationInstructions : repairInstructions), + ...(annotation ? annotationInstructions : strategic ? strategicInstructions : repairInstructions), '', '## Scope', json({ From a90f2757aeda7c1d222f87768f873ab941aabc77 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 21 Oct 2026 08:44:54 -0400 Subject: [PATCH 2/4] fix(studio): keep strategic repair guidance to strategic findings Signed-off-by: Yordis Prieto --- .../studio/src/lib/validation-context.test.ts | 14 ++++++++++++++ .../webapps/studio/src/lib/validation-context.ts | 3 +-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tsworkspace/webapps/studio/src/lib/validation-context.test.ts b/tsworkspace/webapps/studio/src/lib/validation-context.test.ts index dd8a16ae..6f98a854 100644 --- a/tsworkspace/webapps/studio/src/lib/validation-context.test.ts +++ b/tsworkspace/webapps/studio/src/lib/validation-context.test.ts @@ -533,6 +533,20 @@ describe('validationFixPrompt for strategic findings', () => { expect(prompt).not.toContain('whose slug equals the Domain'); }); + it('keeps the general repair guidance for a non-strategic finding on a strategic entity', () => { + const prompt = validationFixPrompt( + strategicModel(), + finding({ + code: 'DANGLING_REF', + ruleCategory: 'reference', + field: 'realizes', + subject: { kind: 'boundedContext', id: sid('accounts', 'accounts') }, + }), + ); + expect(prompt).toContain('Apply a targeted repair that preserves intended behavior'); + expect(prompt).not.toContain('whose slug equals the Domain'); + }); + it('includes the contexts sharing a Domain namespace and the subdomains pointing at the Domain', () => { const prompt = validationFixPrompt( strategicModel(), diff --git a/tsworkspace/webapps/studio/src/lib/validation-context.ts b/tsworkspace/webapps/studio/src/lib/validation-context.ts index eeb1bd6a..efa8d577 100644 --- a/tsworkspace/webapps/studio/src/lib/validation-context.ts +++ b/tsworkspace/webapps/studio/src/lib/validation-context.ts @@ -195,8 +195,7 @@ export function validationFixPrompt( options: { branch?: string; momentKey?: string; scopePath?: string } = {}, ): string { const annotation = annotationCodes.has(issue.code); - const strategic = - Boolean(issue.subject && strategicKinds.has(issue.subject.kind)) || issue.ruleCategory === 'strategic'; + const strategic = issue.ruleCategory === 'strategic'; const selected = new Map(); const referenced = new Map(); const refsByKey = new Map(model.entities.map((entity) => [entity.key, references(entity)])); From c43cea0a7c08f5a8adb4f1c1d233f18d5faf29cc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 22 Oct 2026 21:41:59 -0400 Subject: [PATCH 3/4] fix(studio): load the strategic neighbors a context actually pins Signed-off-by: Yordis Prieto --- .../studio/src/lib/validation-context.test.ts | 17 +++++++++++++++++ .../studio/src/lib/validation-context.ts | 15 +++++++-------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/tsworkspace/webapps/studio/src/lib/validation-context.test.ts b/tsworkspace/webapps/studio/src/lib/validation-context.test.ts index 6f98a854..95849889 100644 --- a/tsworkspace/webapps/studio/src/lib/validation-context.test.ts +++ b/tsworkspace/webapps/studio/src/lib/validation-context.test.ts @@ -604,6 +604,23 @@ describe('validationFixPrompt for strategic findings', () => { expect(loaded(prompt)).not.toContain('boundedContext:unrelated/unrelated@1'); }); + it('follows the pinned version when a context realizes one of several loaded subdomain versions', () => { + const model = buildModel([ + { domain: { id: sid('acme', 'acme') } }, + { domain: { id: id('acme', '2', 'acme') } }, + { subdomain: { id: sid('acme', 'identity'), domain: { id: sid('acme', 'acme') } } }, + { subdomain: { id: id('identity', '2', 'acme'), domain: { id: id('acme', '2', 'acme') } } }, + { boundedContext: { id: sid('accounts', 'accounts'), realizes: [{ id: id('identity', '2', 'acme') }] } }, + ]); + const prompt = validationFixPrompt( + model, + strategicFinding({ kind: 'boundedContext', id: sid('accounts', 'accounts') }, 'REALIZES_MIXED_CLASSIFICATIONS'), + ); + expect(loaded(prompt)).toEqual(expect.arrayContaining(['subdomain:acme/identity@2', 'domain:acme/acme@2'])); + expect(loaded(prompt)).not.toContain('subdomain:acme/identity@1'); + expect(loaded(prompt)).not.toContain('domain:acme/acme@1'); + }); + it('treats the project reference of a domain as a reference to fetch', () => { const model = buildModel([{ domain: { id: sid('acme', 'acme'), project: { id: sid('acme', 'acme') } } }]); const prompt = validationFixPrompt(model, strategicFinding({ kind: 'domain', id: sid('acme', 'acme') })); diff --git a/tsworkspace/webapps/studio/src/lib/validation-context.ts b/tsworkspace/webapps/studio/src/lib/validation-context.ts index efa8d577..6fbfedba 100644 --- a/tsworkspace/webapps/studio/src/lib/validation-context.ts +++ b/tsworkspace/webapps/studio/src/lib/validation-context.ts @@ -154,6 +154,10 @@ function strategicNeighbors(model: Model, subject: Entity): Entity[] { const realizedBy = (subdomain: Entity) => contexts.filter((bc) => realizesOf(bc).some((id) => slugIdentity(id) === slugIdentity(subdomain.id))); const inNamespace = (entities: Entity[]) => entities.filter((entity) => entity.id.namespace === subject.id.namespace); + const pinned = (kind: EntityKind, id: EntityId | undefined) => { + const entity = id && model.byKey.get(entityKey(kind, id)); + return entity ? [entity] : []; + }; if (subject.kind === 'domain') { const children = subdomains.filter((sd) => { @@ -166,20 +170,15 @@ function strategicNeighbors(model: Model, subject: Entity): Entity[] { const parent = domainOf(subject); return [ ...realizedBy(subject), - ...domains.filter((d) => parent && slugIdentity(d.id) === slugIdentity(parent)), + ...pinned('domain', parent), ...subdomains.filter((sd) => { const other = domainOf(sd); return sd.key !== subject.key && parent && other && slugIdentity(other) === slugIdentity(parent); }), ]; } - const realized = realizesOf(subject) - .map((id) => subdomains.find((sd) => slugIdentity(sd.id) === slugIdentity(id))) - .filter((sd): sd is Entity => Boolean(sd)); - const parents = realized.flatMap((sd) => { - const parent = domainOf(sd); - return parent ? domains.filter((d) => slugIdentity(d.id) === slugIdentity(parent)) : []; - }); + const realized = realizesOf(subject).flatMap((id) => pinned('subdomain', id)); + const parents = realized.flatMap((sd) => pinned('domain', domainOf(sd))); return [ ...realized, ...parents, From 18b1913971eaa7ab0fed952b55fa2a9ed229c898 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 22 Oct 2026 21:42:01 -0400 Subject: [PATCH 4/4] fix(studio): let screen readers announce the domain chart finding markers Signed-off-by: Yordis Prieto --- .../studio/src/components/canvas/DomainChartBoard.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tsworkspace/webapps/studio/src/components/canvas/DomainChartBoard.tsx b/tsworkspace/webapps/studio/src/components/canvas/DomainChartBoard.tsx index 6ca8f224..2ae96c3c 100644 --- a/tsworkspace/webapps/studio/src/components/canvas/DomainChartBoard.tsx +++ b/tsworkspace/webapps/studio/src/components/canvas/DomainChartBoard.tsx @@ -53,11 +53,13 @@ function FindingMarker({ marker }: { marker?: ChartMarker }) { return ( f.code).join(', ')}`} className={cn('ml-auto flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[9px] font-bold', tone)} > ); }