184 lines
5.4 KiB
TypeScript
184 lines
5.4 KiB
TypeScript
import { constants } from 'node:fs';
|
|
import { access, stat } from 'node:fs/promises';
|
|
import { join, resolve } from 'node:path';
|
|
|
|
import { createSpawnProcessAdapter } from './adapter.js';
|
|
import { builtInDefinitions, CHECK_SET_POLICY, checkSetForKind } from './definitions.js';
|
|
import type {
|
|
AggregateState,
|
|
CheckResult,
|
|
CheckStatus,
|
|
EvaluateOptions,
|
|
EvaluationReport,
|
|
ProcessAdapter,
|
|
Subject,
|
|
SubjectKind,
|
|
} from './types.js';
|
|
import { detectProjectKind } from '../detect.js';
|
|
|
|
async function pathExists(targetPath: string): Promise<boolean> {
|
|
try {
|
|
await access(targetPath, constants.F_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function isDirectory(targetPath: string): Promise<boolean> {
|
|
try {
|
|
return (await stat(targetPath)).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subject-kind detection for the evaluator. Extends the scaffold detection
|
|
* (detect.ts) with the `monorepo` kind: a pnpm workspace is this repository's
|
|
* own subject shape and carries a different rails file set (probe-inventory
|
|
* gap 7 — check sets are per subject, not one global file list).
|
|
*/
|
|
export async function detectSubjectKind(subjectPath: string): Promise<SubjectKind> {
|
|
if (await pathExists(join(subjectPath, 'pnpm-workspace.yaml'))) {
|
|
return 'monorepo';
|
|
}
|
|
const kind = await detectProjectKind(subjectPath);
|
|
return kind;
|
|
}
|
|
|
|
/**
|
|
* Aggregate state, MACP-style discipline: `passed` only when at least one
|
|
* check produced a verdict AND every verdict is `passed` or an explicitly
|
|
* qualified `not-applicable`. Precedence is fail-closed: error > blocked >
|
|
* failed > passed; an empty result list aggregates to `blocked`.
|
|
*/
|
|
export function aggregateState(results: readonly CheckResult[]): AggregateState {
|
|
if (results.length === 0) {
|
|
return 'blocked';
|
|
}
|
|
const has = (status: CheckStatus): boolean => results.some((result) => result.status === status);
|
|
if (has('error')) {
|
|
return 'error';
|
|
}
|
|
if (has('blocked')) {
|
|
return 'blocked';
|
|
}
|
|
if (has('failed')) {
|
|
return 'failed';
|
|
}
|
|
return 'passed';
|
|
}
|
|
|
|
function reasonFrom(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
/**
|
|
* Evaluate one subject against a set of checks, producing typed verdicts.
|
|
*
|
|
* Fail-closed invariants (RI-N4):
|
|
* - unknown check id → `error` (never passed)
|
|
* - subject directory absent → every verdict `blocked`
|
|
* - check implementation threw → `error`
|
|
* - non-passed without a reason → `error` (no unqualified skips)
|
|
* - check not applicable → `not-applicable` WITH a reason
|
|
*/
|
|
export async function evaluateSubject(options: EvaluateOptions): Promise<EvaluationReport> {
|
|
const subjectPath = resolve(options.subjectPath);
|
|
const subject: Subject = {
|
|
path: subjectPath,
|
|
kind: await detectSubjectKind(subjectPath),
|
|
};
|
|
|
|
const definitions = options.definitions ?? builtInDefinitions();
|
|
const byId = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
const requested = options.checkIds ?? checkSetForKind(subject.kind);
|
|
const adapter: ProcessAdapter = options.adapter ?? createSpawnProcessAdapter();
|
|
|
|
const results: CheckResult[] = [];
|
|
const definitionDigests: Record<string, string> = {};
|
|
|
|
for (const checkId of requested) {
|
|
const definition = byId.get(checkId);
|
|
if (definition === undefined) {
|
|
const known = definitions.map((entry) => entry.id).join(', ');
|
|
results.push({
|
|
status: 'error',
|
|
checkId,
|
|
checkVersion: 'unknown',
|
|
subject: subjectPath,
|
|
reason: `unknown check id '${checkId}' — no registered definition (known: ${known})`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
definitionDigests[checkId] = definition.definitionDigest;
|
|
|
|
if (!(await isDirectory(subjectPath))) {
|
|
results.push({
|
|
status: 'blocked',
|
|
checkId,
|
|
checkVersion: definition.version,
|
|
subject: subjectPath,
|
|
reason: `subject directory does not exist: ${subjectPath}`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!definition.appliesTo.includes(subject.kind)) {
|
|
results.push({
|
|
status: 'not-applicable',
|
|
checkId,
|
|
checkVersion: definition.version,
|
|
subject: subjectPath,
|
|
reason: `check '${checkId}' does not apply to subject kind '${subject.kind}'`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const inputs = options.inputs?.[checkId] ?? {};
|
|
const outcome = await definition.evaluate({
|
|
subject,
|
|
params: definition.params,
|
|
inputs,
|
|
adapter,
|
|
});
|
|
if (outcome.status !== 'passed' && (outcome.reason === undefined || outcome.reason === '')) {
|
|
results.push({
|
|
status: 'error',
|
|
checkId,
|
|
checkVersion: definition.version,
|
|
subject: subjectPath,
|
|
reason: `check returned status '${outcome.status}' without a reason — treated as error`,
|
|
});
|
|
continue;
|
|
}
|
|
results.push({
|
|
status: outcome.status,
|
|
checkId,
|
|
checkVersion: definition.version,
|
|
subject: subjectPath,
|
|
reason: outcome.reason,
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
status: 'error',
|
|
checkId,
|
|
checkVersion: definition.version,
|
|
subject: subjectPath,
|
|
reason: `check implementation threw: ${reasonFrom(error)}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
subject,
|
|
results,
|
|
definitionDigests,
|
|
checkSetVersion: CHECK_SET_POLICY.version,
|
|
state: aggregateState(results),
|
|
};
|
|
}
|