Assessor_2.0/tests/architecture/core-engine-boundary.test.ts

137 lines
7.7 KiB
TypeScript

import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { beforeAll, describe, expect, it } from 'vitest';
// Binding Core/Engine rule (AGENTS.md, "Core Engine and engines"): an engine depends on the Core,
// the Core never depends on an engine, and engines do not build on each other. Engine extension
// points inside Core views are Core code; they are the only Core files allowed to reach an engine
// and are pinned below, so every new Core -> Engine dependency fails here until it is deliberately
// added as an extension point. Engine paths come from the same table the Core guard uses, so a
// newly registered engine is checked without changing this test.
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
interface AreaTable {
ENGINE_PATHS: Array<[string, string[]]>;
classifyPath(relative: string | null): string | null;
}
let areas: AreaTable;
beforeAll(async () => {
const modulePath = pathToFileURL(join(root, '.claude', 'hooks', 'core-areas.mjs')).href;
areas = await import(/* @vite-ignore */ modulePath) as AreaTable;
});
function sourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return sourceFiles(path);
return /\.(?:ts|tsx)$/.test(entry.name) && !entry.name.endsWith('.d.ts') ? [path] : [];
});
}
const posix = (path: string) => relative(root, path).split(sep).join('/');
function relativeImports(file: string): Array<{ target: string; specifier: string; typeOnly: boolean }> {
const source = readFileSync(file, 'utf8');
return [
...[...source.matchAll(/\b(?:import|export)\s+(type\s+)?[^'";]*?\bfrom\s+['"]([^'"]+)['"]/g)]
.map(match => ({ specifier: match[2], typeOnly: Boolean(match[1]) })),
...[...source.matchAll(/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g)].map(match => ({ specifier: match[1], typeOnly: false }))
].filter(use => use.specifier.startsWith('.'))
.map(use => ({ ...use, target: posix(resolve(dirname(file), use.specifier)) }));
}
function engineOf(path: string): string | null {
const lower = path.toLowerCase();
for (const [engine, prefixes] of areas.ENGINE_PATHS) {
if (prefixes.some(prefix => lower === prefix || lower.startsWith(prefix) || `${lower}/` === prefix)) return engine;
}
return null;
}
const allSources = () => [...sourceFiles(join(root, 'src')), ...sourceFiles(join(root, 'server'))];
/**
* Pinned Core -> Engine dependencies: the documented extension points (
* `Context Cluster` Work facet, Finding Reader `Context` section, Pathfinder and sidebar cluster
* readers; Finding Reader ISO mapping, Result DB ISO batch and `Export JSON`, Data Hub ISO corpus upload,
* the Client Info `AI Overview` tab and the Work `ISO control`/`ISO theme` facets), the
* type-only API contract and client, and the server wiring of the engines.
*/
const EXTENSION_POINTS: Record<string, { engine: string | readonly string[]; typeOnly?: boolean }> = {
'src/features/related-findings/RelatedFindings.tsx': { engine: 'Remediation Engine' },
'src/features/search-filter/business-impact-controls.ts': { engine: ['Report Engine', 'Context Engine'] },
'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine', 'Compliance Engine'] },
'src/api/assessor-api-contract.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'], typeOnly: true },
'src/api/assessor-api-client.ts': { engine: 'Compliance Engine', typeOnly: true },
'src/features/finding-reader/FindingReader.tsx': { engine: ['Remediation Engine', 'Context Engine', 'Compliance Engine'] },
'src/features/results-browser/ResultsBrowser.tsx': { engine: 'Compliance Engine' },
'src/features/project-import/DataHub.tsx': { engine: 'Compliance Engine' },
'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/finding-reader/FindingContext.tsx': { engine: 'Context Engine' },
'src/features/mitre-graph/graph-filter-bridge.ts': { engine: 'Context Engine' },
'src/features/mitre-graph/ObsidianGraphView.tsx': { engine: 'Context Engine' },
'src/features/search-filter/filter-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
'server/assessor-http-server.ts': { engine: 'any' },
'server/index.ts': { engine: ['Report Engine', 'Remediation Engine', 'Compliance Engine'] }
};
// Core areas that hold data, evaluation and rules. No file in them may reach any engine,
// not even through an extension point.
const ENGINE_FREE_AREAS = ['Ingest', 'Normalization', 'Evaluation', 'Store', 'Disclosure', 'Client Records',
'Reference Catalogs', 'Shared Kernel'];
describe('Core Engine and engines boundary', () => {
it('knows the registered engines and finds source files for each of them', () => {
const engines = areas.ENGINE_PATHS.map(([engine]) => engine);
expect(engines).toEqual(expect.arrayContaining(['Context Engine', 'Report Engine', 'Remediation Engine', 'Compliance Engine']));
for (const engine of engines) {
expect(allSources().some(file => engineOf(posix(file)) === engine), engine).toBe(true);
}
});
it('keeps data, evaluation and rule areas of the Core free of every engine', () => {
const violations = allSources()
.filter(file => ENGINE_FREE_AREAS.includes(areas.classifyPath(posix(file).toLowerCase()) ?? ''))
.flatMap(file => relativeImports(file)
.filter(use => engineOf(use.target))
.map(use => `${posix(file)} (${areas.classifyPath(posix(file).toLowerCase())}) -> ${use.specifier}`));
expect(violations).toEqual([]);
});
it('lets the Core reach an engine only through a pinned extension point', () => {
const violations = allSources()
.filter(file => !engineOf(posix(file)))
.flatMap(file => relativeImports(file).flatMap(use => {
const engine = engineOf(use.target);
if (!engine) return [];
const allowed = EXTENSION_POINTS[posix(file)];
const ok = allowed && (allowed.engine === 'any' || allowed.engine === engine || (Array.isArray(allowed.engine) && allowed.engine.includes(engine))) && (!allowed.typeOnly || use.typeOnly);
return ok ? [] : [`${posix(file)} -> ${use.specifier} (${engine}${use.typeOnly ? ', type' : ''})`];
}));
expect(violations).toEqual([]);
});
it('keeps every pinned extension point real, so the list cannot silently grow stale', () => {
const stale = Object.keys(EXTENSION_POINTS).filter(path => {
const file = join(root, ...path.split('/'));
return !relativeImports(file).some(use => engineOf(use.target));
});
expect(stale).toEqual([]);
});
it('keeps engines independent of each other', () => {
const violations = allSources().flatMap(file => {
const own = engineOf(posix(file));
if (!own) return [];
return relativeImports(file)
.filter(use => { const other = engineOf(use.target); return other !== null && other !== own; })
.map(use => `${posix(file)} (${own}) -> ${use.specifier} (${engineOf(use.target)})`);
});
expect(violations).toEqual([]);
});
});