Assessor_2.0/tests/reporting/reporting-boundary.test.ts

123 lines
5.7 KiB
TypeScript

import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
// Architecture boundary of the Report Engine (Phase 7, decided 2026-09-30):
// the engine lives in src/reporting with its server adapters in server/reporting,
// only the server route reaches into it, and it never reaches a writing repository method.
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const reporting = join(root, 'src', 'reporting');
const serverReporting = join(root, 'server', 'reporting');
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 read = (path: string) => readFileSync(path, 'utf8');
const posix = (path: string) => relative(root, path).split(sep).join('/');
interface ImportUse { specifier: string; typeOnly: boolean }
function imports(source: string): ImportUse[] {
const uses: ImportUse[] = [];
for (const match of source.matchAll(/\bimport\s+(type\s+)?[^'";]*?\bfrom\s+['"]([^'"]+)['"]/g)) {
uses.push({ specifier: match[2], typeOnly: Boolean(match[1]) });
}
for (const match of source.matchAll(/\bexport\s+(type\s+)?[^'";]*?\bfrom\s+['"]([^'"]+)['"]/g)) {
uses.push({ specifier: match[2], typeOnly: Boolean(match[1]) });
}
for (const match of source.matchAll(/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g)) {
uses.push({ specifier: match[1], typeOnly: false });
}
return uses;
}
function resolvesIntoReporting(file: string, specifier: string): boolean {
if (!specifier.startsWith('.')) return false;
const target = resolve(dirname(file), specifier);
return target === reporting || target.startsWith(reporting + sep);
}
/**
* Importers allowed to reach the engine: the server route and the engine's own server adapters.
* Client Info additionally consumes the published impact distribution through its pinned read-only extension.
*/
const allowedImporters: Record<string, 'any' | 'type-only'> = {
'src/features/search-filter/business-impact-controls.ts': 'any',
'src/features/dashboard/DashboardPanel.tsx': 'any',
'server/assessor-http-server.ts': 'any'
};
const outsideFiles = [...sourceFiles(join(root, 'src')), ...sourceFiles(join(root, 'server'))]
.filter(file => !file.startsWith(reporting + sep) && !file.startsWith(serverReporting + sep));
const reportingFiles = sourceFiles(reporting);
const serverReportingFiles = sourceFiles(serverReporting);
describe('Report Engine boundary', () => {
it('keeps the report modules together in src/reporting', () => {
expect(reportingFiles.map(posix).sort()).toEqual([
'src/reporting/client-report-projection.ts',
'src/reporting/context-management-report.ts',
'src/reporting/management-business-impact.ts',
'src/reporting/management-overview-report.ts',
'src/reporting/management-report-texts.ts',
'src/reporting/report-brand.ts',
'src/reporting/report-charts.ts',
'src/reporting/report-context.ts',
'src/reporting/report-correlation.ts',
'src/reporting/report-font.ts',
'src/reporting/report-header-facts.ts',
'src/reporting/report-input-assembler.ts',
'src/reporting/report-narrative.ts',
'src/reporting/report-projection.ts',
'src/reporting/report-read-port.ts',
'src/reporting/report-rich-text.ts',
'src/reporting/report-run.ts',
'src/reporting/report-statement-text.ts',
'src/reporting/report-workspace-view.ts',
'src/reporting/technical-report-model.ts',
'src/reporting/technical-report.ts'
]);
});
it('is reached only by the server route and its own server adapters', () => {
const violations = outsideFiles.flatMap(file => imports(read(file))
.filter(use => resolvesIntoReporting(file, use.specifier))
.flatMap(use => {
const rule = allowedImporters[posix(file)];
if (rule === 'any' || (rule === 'type-only' && use.typeOnly)) return [];
return [`${posix(file)} -> ${use.specifier}${use.typeOnly ? ' (type)' : ''}`];
}));
expect(violations).toEqual([]);
});
it('never reaches a writing repository method', () => {
const repositorySource = read(join(root, 'src', 'persistence', 'assessor-repository.ts'));
const body = repositorySource.slice(repositorySource.indexOf('export interface AssessorRepository'));
const methods = [...body.matchAll(/^\s{2}(\w+)\(/gm)].map(match => match[1]);
const readPrefixes = /^(?:list|get|load|query|find|export)[A-Z]/;
const writeMethods = methods.filter(name => !readPrefixes.test(name));
expect(writeMethods).toContain('saveReview');
expect(writeMethods).toContain('commitCanonicalImport');
const violations = [...reportingFiles, ...serverReportingFiles].flatMap(file => writeMethods
.filter(name => new RegExp(`\\.${name}\\s*\\(`).test(read(file)))
.map(name => `${posix(file)} calls ${name}`));
expect(violations).toEqual([]);
});
it('stays independent of UI runtimes, the server and database clients', () => {
const forbidden = /^(?:react|react-dom|@tauri|pg|postgres)(?:\/|$)/;
const violations = reportingFiles.flatMap(file => imports(read(file))
.filter(use => forbidden.test(use.specifier)
|| (use.specifier.startsWith('.') && [join(root, 'server'), join(root, 'src', 'app'), join(root, 'src', 'components')]
.some(directory => resolve(dirname(file), use.specifier).startsWith(directory + sep))))
.map(use => `${posix(file)} -> ${use.specifier}`));
expect(violations).toEqual([]);
});
});