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 { try { await access(targetPath, constants.F_OK); return true; } catch { return false; } } async function isDirectory(targetPath: string): Promise { 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 { 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 { 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 = {}; 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), }; }