130 lines
7.9 KiB
TypeScript
130 lines
7.9 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 the isolated Client Info heatmap extension 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)))
|
|
.map(use => `${posix(file)} -> ${use.specifier}`));
|
|
expect(violations).toEqual([]);
|
|
});
|
|
});
|