Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/discovery/architecture-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { loadArchitectureBundle } from './architecture-bundle.js';

let tempDir: string;

beforeEach(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'architecture-bundle-test-'));
});

afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});

async function writeYaml(relativePath: string, content: string): Promise<void> {
const fullPath = path.join(tempDir, relativePath);
await mkdir(path.dirname(fullPath), { recursive: true });
await writeFile(fullPath, content, 'utf8');
}

async function writeRequiredBundle(options: { includeArchitectureGraph?: boolean } = {}): Promise<void> {
await writeYaml('adrs/index/architecture-index.yaml', `
schema_version: '1.1'
type: architecture_index
architecture_namespace: sample-runtime
generated_at: '2026-03-19T00:00:00Z'
entity_registry_path: adrs/index/entity-registry.yaml
relationship_registry_path: adrs/index/relationship-registry.yaml
unresolved_registry_path: adrs/index/unresolved-registry.yaml
decision_registry_path: adrs/index/decision-registry.yaml
`);
await writeYaml('adrs/manifest.yaml', `
schema_version: '1.0'
type: manifest
generated_date: '2026-03-19T00:00:00Z'
adrs:
- id: ADR-L-0013
`);
await writeYaml('adrs/index/entity-registry.yaml', `
entities:
- entity_id: CAP-0001
`);
await writeYaml('adrs/index/relationship-registry.yaml', `
relationships:
- relationship_id: REL-0001
`);
await writeYaml('adrs/index/unresolved-registry.yaml', `
unresolved: []
`);
await writeYaml('adrs/index/decision-registry.yaml', `
decisions:
- decision_id: DEC-0001
`);

if (options.includeArchitectureGraph !== false) {
await writeYaml('adrs/index/architecture-graph.yaml', `
nodes:
- id: CAP-0001
edges: []
`);
}
}

describe('loadArchitectureBundle', () => {
it('loads the required bundle and reports a valid status when additive artifacts are present', async () => {
await writeRequiredBundle();

const result = await loadArchitectureBundle(tempDir);

expect(result.status).toBe('valid');
expect(result.index.architectureNamespace).toBe('sample-runtime');
expect(result.manifest.generatedDate).toBe('2026-03-19T00:00:00Z');
expect(result.manifest.adrCount).toBe(1);
expect(result.requiredArtifacts.entityRegistry.exists).toBe(true);
expect(result.additiveArtifacts.architectureGraph.exists).toBe(true);
});

it('returns invalid when a required registry is missing', async () => {
await writeRequiredBundle();
await rm(path.join(tempDir, 'adrs/index/relationship-registry.yaml'));

const result = await loadArchitectureBundle(tempDir);

expect(result.status).toBe('invalid');
expect(result.errors.some((error) => error.includes('relationship-registry.yaml'))).toBe(true);
});

it('returns degraded when the additive architecture graph is unavailable', async () => {
await writeRequiredBundle({ includeArchitectureGraph: false });

const result = await loadArchitectureBundle(tempDir);

expect(result.status).toBe('degraded');
expect(result.errors).toEqual([]);
expect(result.warnings.some((warning) => warning.includes('Additive architecture graph unavailable'))).toBe(true);
});

it('does not consult the legacy compatibility registry', async () => {
await writeRequiredBundle();
await writeYaml('adrs/entities/registry.yaml', 'this: [is: not-valid-yaml');

const result = await loadArchitectureBundle(tempDir);

expect(result.status).toBe('valid');
expect(result.errors).toEqual([]);
expect(result.warnings.some((warning) => warning.includes('intentionally not consulted'))).toBe(true);
});
});
260 changes: 260 additions & 0 deletions src/discovery/architecture-bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';

import yaml from 'js-yaml';

export type ArchitectureBundleStatus = 'valid' | 'degraded' | 'invalid';

export interface ArchitectureBundleArtifact<T = unknown> {
path: string;
exists: boolean;
data?: T;
error?: string;
}

export interface ArchitectureBundleManifestSummary {
schemaVersion?: string;
generatedDate?: string;
adrCount?: number;
}

export interface ArchitectureBundleIndexSummary {
schemaVersion?: string;
architectureNamespace?: string;
generatedAt?: string;
}

export interface ArchitectureBundleResult {
status: ArchitectureBundleStatus;
scopeRoot: string;
requiredArtifacts: {
architectureIndex: ArchitectureBundleArtifact<Record<string, unknown>>;
manifest: ArchitectureBundleArtifact<Record<string, unknown>>;
entityRegistry: ArchitectureBundleArtifact<unknown>;
relationshipRegistry: ArchitectureBundleArtifact<unknown>;
unresolvedRegistry: ArchitectureBundleArtifact<unknown>;
};
additiveArtifacts: {
architectureGraph: ArchitectureBundleArtifact<unknown>;
subsetRegistries: ArchitectureBundleArtifact<unknown>[];
};
manifest: ArchitectureBundleManifestSummary;
index: ArchitectureBundleIndexSummary;
warnings: string[];
errors: string[];
}

const REQUIRED_INDEX_KEYS = {
entityRegistry: 'entity_registry_path',
relationshipRegistry: 'relationship_registry_path',
unresolvedRegistry: 'unresolved_registry_path',
} as const;

const REQUIRED_DEFAULT_PATHS = {
entityRegistry: 'adrs/index/entity-registry.yaml',
relationshipRegistry: 'adrs/index/relationship-registry.yaml',
unresolvedRegistry: 'adrs/index/unresolved-registry.yaml',
} as const;

const ARCHITECTURE_INDEX_PATH = 'adrs/index/architecture-index.yaml';
const MANIFEST_PATH = 'adrs/manifest.yaml';
const ARCHITECTURE_GRAPH_PATH = 'adrs/index/architecture-graph.yaml';
const LEGACY_ENTITY_REGISTRY_PATH = 'adrs/entities/registry.yaml';

async function readYamlArtifact<T = unknown>(absolutePath: string): Promise<ArchitectureBundleArtifact<T>> {
try {
const raw = await fs.readFile(absolutePath, 'utf8');
const data = yaml.load(raw);
if (data === undefined) {
return {
path: absolutePath,
exists: true,
error: 'YAML document is empty',
};
}
return {
path: absolutePath,
exists: true,
data: data as T,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const code = (error as NodeJS.ErrnoException | undefined)?.code;
if (code === 'ENOENT') {
return {
path: absolutePath,
exists: false,
error: 'File not found',
};
}
return {
path: absolutePath,
exists: true,
error: message,
};
}
}

function getStringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === 'string' && value.trim() ? value : undefined;
}

function normalizeArtifactPath(scopeRoot: string, relativePath: string): string {
return path.resolve(scopeRoot, relativePath);
}

function resolveRequiredPath(
scopeRoot: string,
indexData: Record<string, unknown> | undefined,
indexKey: string,
fallbackPath: string,
warnings: string[],
): string {
const configuredPath = indexData ? getStringField(indexData, indexKey) : undefined;
if (configuredPath) {
return normalizeArtifactPath(scopeRoot, configuredPath);
}
warnings.push(`Architecture index did not declare ${indexKey}; falling back to ${fallbackPath}.`);
return normalizeArtifactPath(scopeRoot, fallbackPath);
}

function summarizeManifest(manifestData: Record<string, unknown> | undefined): ArchitectureBundleManifestSummary {
const adrs = manifestData?.adrs;
return {
schemaVersion: manifestData ? getStringField(manifestData, 'schema_version') : undefined,
generatedDate: manifestData ? getStringField(manifestData, 'generated_date') : undefined,
adrCount: Array.isArray(adrs) ? adrs.length : undefined,
};
}

function summarizeIndex(indexData: Record<string, unknown> | undefined): ArchitectureBundleIndexSummary {
return {
schemaVersion: indexData ? getStringField(indexData, 'schema_version') : undefined,
architectureNamespace: indexData ? getStringField(indexData, 'architecture_namespace') : undefined,
generatedAt: indexData ? getStringField(indexData, 'generated_at') : undefined,
};
}

export async function loadArchitectureBundle(scopeRoot: string): Promise<ArchitectureBundleResult> {
const resolvedRoot = path.resolve(scopeRoot);
const warnings: string[] = [];
const errors: string[] = [];

const architectureIndex = await readYamlArtifact<Record<string, unknown>>(
normalizeArtifactPath(resolvedRoot, ARCHITECTURE_INDEX_PATH),
);
const manifest = await readYamlArtifact<Record<string, unknown>>(
normalizeArtifactPath(resolvedRoot, MANIFEST_PATH),
);

const indexData =
architectureIndex.data && typeof architectureIndex.data === 'object' && !Array.isArray(architectureIndex.data)
? architectureIndex.data
: undefined;

if (architectureIndex.error) {
errors.push(`Required architecture index is unavailable: ${architectureIndex.error}.`);
} else if (!indexData) {
errors.push('Required architecture index is malformed.');
}

if (manifest.error) {
errors.push(`Required manifest is unavailable: ${manifest.error}.`);
} else if (!manifest.data || typeof manifest.data !== 'object' || Array.isArray(manifest.data)) {
errors.push('Required manifest is malformed.');
}

const entityRegistryPath = resolveRequiredPath(
resolvedRoot,
indexData,
REQUIRED_INDEX_KEYS.entityRegistry,
REQUIRED_DEFAULT_PATHS.entityRegistry,
warnings,
);
const relationshipRegistryPath = resolveRequiredPath(
resolvedRoot,
indexData,
REQUIRED_INDEX_KEYS.relationshipRegistry,
REQUIRED_DEFAULT_PATHS.relationshipRegistry,
warnings,
);
const unresolvedRegistryPath = resolveRequiredPath(
resolvedRoot,
indexData,
REQUIRED_INDEX_KEYS.unresolvedRegistry,
REQUIRED_DEFAULT_PATHS.unresolvedRegistry,
warnings,
);

const [entityRegistry, relationshipRegistry, unresolvedRegistry, architectureGraph] = await Promise.all([
readYamlArtifact(entityRegistryPath),
readYamlArtifact(relationshipRegistryPath),
readYamlArtifact(unresolvedRegistryPath),
readYamlArtifact(normalizeArtifactPath(resolvedRoot, ARCHITECTURE_GRAPH_PATH)),
]);

const subsetRegistryArtifacts: ArchitectureBundleArtifact<unknown>[] = [];
if (indexData) {
for (const [key, value] of Object.entries(indexData)) {
if (!key.endsWith('_registry_path')) continue;
if (Object.values(REQUIRED_INDEX_KEYS).includes(key as (typeof REQUIRED_INDEX_KEYS)[keyof typeof REQUIRED_INDEX_KEYS])) {
continue;
}
if (typeof value !== 'string' || !value.trim()) {
warnings.push(`Architecture index declared ${key} without a usable path.`);
continue;
}
subsetRegistryArtifacts.push(await readYamlArtifact(normalizeArtifactPath(resolvedRoot, value)));
}
}

const requiredArtifacts = [entityRegistry, relationshipRegistry, unresolvedRegistry];
for (const artifact of requiredArtifacts) {
if (artifact.error) {
errors.push(`Required bundle artifact ${path.relative(resolvedRoot, artifact.path)} is unavailable: ${artifact.error}.`);
}
}

if (architectureGraph.error) {
warnings.push(`Additive architecture graph unavailable: ${architectureGraph.error}.`);
}
for (const artifact of subsetRegistryArtifacts) {
if (artifact.error) {
warnings.push(`Additive subset registry ${path.relative(resolvedRoot, artifact.path)} unavailable: ${artifact.error}.`);
}
}

const legacyRegistryPath = normalizeArtifactPath(resolvedRoot, LEGACY_ENTITY_REGISTRY_PATH);
try {
await fs.access(legacyRegistryPath);
warnings.push('Legacy compatibility registry detected at adrs/entities/registry.yaml but intentionally not consulted.');
} catch {
// Compatibility surface absent; nothing to report.
}

const status: ArchitectureBundleStatus =
errors.length > 0 ? 'invalid' : warnings.some((warning) => warning.startsWith('Additive ')) ? 'degraded' : 'valid';

return {
status,
scopeRoot: resolvedRoot,
requiredArtifacts: {
architectureIndex,
manifest,
entityRegistry,
relationshipRegistry,
unresolvedRegistry,
},
additiveArtifacts: {
architectureGraph,
subsetRegistries: subsetRegistryArtifacts,
},
manifest: summarizeManifest(
manifest.data && typeof manifest.data === 'object' && !Array.isArray(manifest.data) ? manifest.data : undefined,
),
index: summarizeIndex(indexData),
warnings,
errors,
};
}
9 changes: 9 additions & 0 deletions src/discovery/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,14 @@ export {
type ProjectStructure
} from './project-discovery.js';

export {
loadArchitectureBundle,
type ArchitectureBundleArtifact,
type ArchitectureBundleIndexSummary,
type ArchitectureBundleManifestSummary,
type ArchitectureBundleResult,
type ArchitectureBundleStatus,
} from './architecture-bundle.js';



Loading
Loading