Assessor_2.0/tests/assessment-context/context-boundary.test.ts

132 lines
8.1 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';
import { createAssessmentContext } from '../../src/assessment-context/context-run';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
// Binding boundary of the context layer (AGENTS.md, Phase 13E): a parallel, read-only second
// reading. It is reached only through the server route and type-only API contract, it never calls
// a writing repository method and it never changes stored ASSESSOR data.
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const engine = join(root, 'src', 'assessment-context');
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('/');
function imports(source: string): Array<{ specifier: string; typeOnly: boolean }> {
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 }))
];
}
const allowedImporters: Record<string, 'any' | 'type-only'> = {
'src/features/search-filter/business-impact-controls.ts': 'type-only',
'server/assessor-http-server.ts': 'any',
'src/api/assessor-api-contract.ts': 'type-only',
// Context-owned UI reads only the published heatmap projection; no Core view reads engine internals.
'src/features/context-layer/ContextHeatmap.tsx': 'any',
'src/features/context-layer/context-cluster-facet.ts': 'any'
};
const engineFiles = sourceFiles(engine);
describe('context layer boundary', () => {
it('keeps the engine together in src/assessment-context', () => {
expect(engineFiles.map(posix).sort()).toEqual([
'src/assessment-context/context-assignment.ts',
'src/assessment-context/context-catalog.ts',
'src/assessment-context/context-cluster-severity.ts',
'src/assessment-context/context-engine.ts',
'src/assessment-context/context-gaps.ts',
'src/assessment-context/context-heatmap.ts',
'src/assessment-context/context-recognition.ts',
'src/assessment-context/context-report-publication.ts',
'src/assessment-context/context-run.ts'
]);
});
it('is reached only by the server, type-only API contract and its own heatmap projection consumer', () => {
const outside = [...sourceFiles(join(root, 'src')), ...sourceFiles(join(root, 'server'))]
.filter(file => !file.startsWith(engine + sep));
const violations = outside.flatMap(file => imports(read(file))
.filter(use => use.specifier.startsWith('.') && resolve(dirname(file), use.specifier).startsWith(engine + sep))
.flatMap(use => {
const rule = allowedImporters[posix(file)];
const publishedImpact = posix(file) === 'src/features/dashboard/DashboardPanel.tsx'
&& use.specifier === '../../assessment-context/context-report-publication';
return publishedImpact || rule === 'any' || (rule === 'type-only' && use.typeOnly) ? [] : [`${posix(file)} -> ${use.specifier}`];
}));
expect(violations).toEqual([]);
});
it('never calls a writing repository method and depends on no UI, server, report or database module', () => {
const repository = read(join(root, 'src', 'persistence', 'assessor-repository.ts'));
const methods = [...repository.slice(repository.indexOf('export interface AssessorRepository')).matchAll(/^\s{2}(\w+)\(/gm)].map(match => match[1]);
const writeMethods = methods.filter(name => !/^(?:list|get|load|query|find|export)[A-Z]/.test(name));
expect(writeMethods).toContain('savePolicyEvaluation');
// User decision 2026-10-01: the engine reads everything Raw Data shows. That data comes from the workspace loader,
// a read-only data projection (no rendering), which the Report Engine also uses in the backend. It is the only
// permitted module under src/features.
const rawDataLoader = join(root, 'src', 'features', 'project-import', 'load-canonical-project-workspace');
const violations = engineFiles.flatMap(file => [
...writeMethods.filter(name => new RegExp(`\\.${name}\\s*\\(`).test(read(file))).map(name => `${posix(file)} calls ${name}`),
...imports(read(file)).filter(use => /^(?:react|react-dom|@tauri|pg|postgres)(?:\/|$)/.test(use.specifier)
|| (use.specifier.startsWith('.') && resolve(dirname(file), use.specifier) !== rawDataLoader
&& ['server', 'src/app', 'src/components', 'src/features', 'src/reporting']
.some(directory => resolve(dirname(file), use.specifier).startsWith(join(root, directory) + sep))))
.map(use => `${posix(file)} -> ${use.specifier}`)
]);
expect(violations).toEqual([]);
});
it('leaves every stored ASSESSOR value and the repository revision unchanged', async () => {
const repository = new LocalAssessorRepository();
const timestamp = '2026-09-30T08:00:00.000Z';
await repository.saveProject({ id: 'dummy-project', name: 'Dummy Customer', createdAt: timestamp, updatedAt: timestamp, attributes: {} });
await repository.saveAssessment({ id: 'dummy-assessment', projectId: 'dummy-project', name: 'Dummy', createdAt: timestamp, updatedAt: timestamp });
const before = { revision: repository.revision, data: JSON.stringify(await repository.exportProject('dummy-project')) };
const context = await createAssessmentContext({
queryUnifiedResults: query => repository.queryUnifiedResults(query),
listGpoPolicyAnalyses: projectId => repository.listGpoPolicyAnalyses(projectId),
listReferenceRuns: projectId => repository.listReferenceRuns(projectId),
listPolicyEvaluations: projectId => repository.listPolicyEvaluations(projectId),
getEnvironmentSnapshot: query => repository.getEnvironmentSnapshot(query),
currentRevision: async () => repository.revision
}, { projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: timestamp });
expect(context.repositoryRevision).toBe(before.revision);
expect(repository.revision).toBe(before.revision);
expect(JSON.stringify(await repository.exportProject('dummy-project'))).toBe(before.data);
});
});
describe('context layer cannot influence Core score or Result DB', () => {
it('allows only pinned Client Info context presentation extensions and keeps Core calculations free of context', () => {
const guarded = [
...sourceFiles(join(root, 'src', 'features', 'dashboard')),
...sourceFiles(join(root, 'src', 'scoring')),
join(root, 'src', 'domain', 'security-scoring.ts'),
join(root, 'src', 'domain', 'result-db-materialization.ts'),
join(root, 'src', 'domain', 'result-db-disclosure.ts'),
...sourceFiles(join(root, 'server', 'postgres'))
];
const contextDirectories = [join(root, 'src', 'assessment-context'), join(root, 'src', 'features', 'context-layer')];
const violations = guarded.flatMap(file => imports(read(file))
.filter(use => use.specifier.startsWith('.') && contextDirectories.some(directory => resolve(dirname(file), use.specifier).startsWith(directory + sep)))
.filter(use => !((posix(file) === 'src/features/dashboard/DashboardPanel.tsx'
&& ['../context-layer/ContextHeatmap', '../context-layer/context-cluster-facet', '../context-layer/use-assessment-context', '../../assessment-context/context-report-publication'].includes(use.specifier))
|| (posix(file) === 'src/features/dashboard/ClientWorkPackagesPanel.tsx'
&& use.specifier === '../context-layer/use-context-cluster-index')))
.map(use => `${posix(file)} -> ${use.specifier}`));
expect(violations).toEqual([]);
});
});