133 lines
7.1 KiB
TypeScript
133 lines
7.1 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), the type-only API contract and the server wiring of both 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'] },
|
|
'src/features/dashboard/ClientWorkPackagesPanel.tsx': { engine: ['Remediation Engine', 'Context Engine'] },
|
|
'src/api/assessor-api-contract.ts': { engine: ['Context Engine', 'Remediation Engine'], typeOnly: true },
|
|
'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Remediation Engine'] },
|
|
'src/features/finding-reader/FindingReader.tsx': { engine: ['Remediation Engine', 'Context 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'] },
|
|
'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Remediation Engine'] },
|
|
'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Remediation Engine'] },
|
|
'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Remediation Engine'] },
|
|
'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Remediation Engine'] },
|
|
'server/assessor-http-server.ts': { engine: 'any' },
|
|
'server/index.ts': { engine: ['Report Engine', 'Remediation 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']));
|
|
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([]);
|
|
});
|
|
});
|