124 lines
5.8 KiB
TypeScript
124 lines
5.8 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/security-view-report.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([]);
|
|
});
|
|
});
|