import { constants } from 'node:fs'; import { access } from 'node:fs/promises'; import { isAbsolute, resolve } from 'node:path'; import { digestOfPolicy, digestOfSpec } from './digest.js'; import type { CheckContext, CheckDefinition, CheckDefinitionSpec, CheckOutcome, CheckSetPolicy, CheckSetPolicySpec, SubjectKind, } from './types.js'; // Check definitions for the RI-N4 evaluator (card RI-3-002). Each definition is // DATA with a version and a content digest (see digest.ts); the executable // half is attached via defineCheck. Check-set SELECTION is per subject kind // (probe-inventory gap 7): this monorepo does not match the node template's // file list, so the QC-19 definition carries a distinct file set for the // `monorepo` subject kind and the policy selects checks per kind. export function defineCheck( spec: CheckDefinitionSpec, evaluate: (ctx: CheckContext) => Promise, ): CheckDefinition { return { ...spec, definitionDigest: digestOfSpec(spec), evaluate }; } async function fileExists(filePath: string): Promise { try { await access(filePath, constants.F_OK); return true; } catch { return false; } } // ─── QC-19: downstream rails presence ──────────────────────────────────────── // // Typed absorption of the former presence-only `quality-rails check` loop in // cli.ts. The scaffold-kind file lists below are carried over VERBATIM so the // evaluator's typed verdicts are parity-equivalent with the presence loop on // the same fixture; the `monorepo` list is new (per-subject check sets). const qc19Spec: CheckDefinitionSpec = { id: 'qc-19-rails-files-present', version: '1.0.0', canonicalCheck: 'QC-19', description: 'The subject still carries its quality-rails files. Typed absorption of the former presence-only check loop; presence is necessary, not sufficient (RI-N4).', appliesTo: ['node', 'python', 'rust', 'monorepo', 'unknown'], params: { expectedFilesByKind: { node: ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md'], python: ['pyproject.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'], rust: ['rustfmt.toml', '.githooks/pre-commit', 'PR-CHECKLIST.md'], monorepo: [ '.husky/pre-commit', '.husky/pre-push', 'eslint.config.mjs', '.prettierrc', '.lintstagedrc', ], unknown: ['.githooks/pre-commit', 'PR-CHECKLIST.md'], }, }, }; async function evaluateQc19(ctx: CheckContext): Promise { const byKind = ctx.params['expectedFilesByKind'] as Record | undefined; if (byKind === undefined) { return { status: 'error', reason: 'definition params missing expectedFilesByKind' }; } const expected = byKind[ctx.subject.kind]; if (expected === undefined) { // Fail-closed: an undefined file set for a declared subject kind is a // definition gap, never a green outcome. return { status: 'blocked', reason: `no expected-file set defined for subject kind '${ctx.subject.kind}'`, }; } const missing: string[] = []; for (const relativePath of expected) { if (!(await fileExists(resolve(ctx.subject.path, relativePath)))) { missing.push(relativePath); } } if (missing.length > 0) { return { status: 'failed', reason: `missing rails files (${ctx.subject.kind}): ${missing.join(', ')}`, }; } return { status: 'passed' }; } // ─── QC-20: downstream enforcement verification (behavioral probe) ────────── // // The planted-commit behavioral probe (framework tools/quality/scripts/verify.sh) // stays a THIN SHELL ADAPTER: the TS evaluator invokes it and OWNS the verdict // parsing (RI-N4: grep-on-output verdict logic moves into the typed evaluator). // Probe contract (verify.sh): exit 0 ⇔ every sub-probe passed, exit 1 ⇔ at // least one sub-probe failed; sub-probe verdicts appear as `PASS:` / `FAIL:` // marker lines and the script always prints a `Verification Summary` section. // Any deviation from that contract (other exit codes, unparseable output, // missing probe, process failure, timeout) is `error`/`blocked` — never // `passed`. const qc20Spec: CheckDefinitionSpec = { id: 'qc-20-enforcement-verify', version: '1.0.0', canonicalCheck: 'QC-20', description: 'The behavioral planted-commit probe runs against the subject and every sub-probe blocks as intended. The shell probe is a thin adapter; verdict parsing is owned by this evaluator.', appliesTo: ['node', 'python', 'rust', 'unknown'], params: { command: 'bash', timeoutMs: 120_000, passMarker: 'PASS:', failMarker: 'FAIL:', summaryMarker: 'Verification Summary', }, }; function linesWith(text: string, marker: string): string[] { return text .split('\n') .map((line) => line.trim()) .filter((line) => line.includes(marker)); } async function evaluateQc20(ctx: CheckContext): Promise { const rawProbePath = ctx.inputs['probePath']; if (typeof rawProbePath !== 'string' || rawProbePath.trim().length === 0) { return { status: 'blocked', reason: 'missing input: probePath — the behavioral probe script must be provided (e.g. the framework verify.sh)', }; } const probePath = isAbsolute(rawProbePath) ? rawProbePath : resolve(ctx.subject.path, rawProbePath); if (!(await fileExists(probePath))) { return { status: 'blocked', reason: `probe script not found: ${probePath}` }; } const command = typeof ctx.params['command'] === 'string' ? ctx.params['command'] : 'bash'; const timeoutMs = typeof ctx.params['timeoutMs'] === 'number' ? ctx.params['timeoutMs'] : 120_000; const passMarker = typeof ctx.params['passMarker'] === 'string' ? ctx.params['passMarker'] : 'PASS:'; const failMarker = typeof ctx.params['failMarker'] === 'string' ? ctx.params['failMarker'] : 'FAIL:'; const summaryMarker = typeof ctx.params['summaryMarker'] === 'string' ? ctx.params['summaryMarker'] : 'Verification Summary'; const outcome = await ctx.adapter.run({ file: command, args: [probePath], cwd: ctx.subject.path, timeoutMs, }); if (!outcome.ok) { // Process error or timeout: the probe never produced a trustworthy result. return { status: 'error', reason: `probe process ${outcome.kind}: ${outcome.message}`, }; } const output = `${outcome.stdout}\n${outcome.stderr}`; const failLines = linesWith(output, failMarker); const passLines = linesWith(output, passMarker); if (outcome.exitCode === 0) { // A green exit must be corroborated by a parseable green transcript: // at least one pass marker, no fail markers, and the summary section. if (passLines.length > 0 && failLines.length === 0 && output.includes(summaryMarker)) { return { status: 'passed' }; } return { status: 'error', reason: `malformed probe output: exit 0 without a parseable pass transcript (${passLines.length} pass markers, ${failLines.length} fail markers, summary ${output.includes(summaryMarker) ? 'present' : 'absent'})`, }; } if (outcome.exitCode === 1) { if (failLines.length === 0) { return { status: 'error', reason: 'malformed probe output: exit 1 without parseable FAIL markers', }; } return { status: 'failed', reason: `enforcement probe reported ${failLines.length} failing sub-probe(s): ${failLines.join(' | ')}`, }; } return { status: 'error', reason: `probe exited with unexpected code ${String(outcome.exitCode)} — outcome not interpretable`, }; } // ─── Per-subject check-set policy ─────────────────────────────────────────── // // Gap 7 of the probe inventory: check sets must be selected per subject, not // one global list. Downstream scaffold kinds get the presence check plus the // behavioral probe (QC-20 blocks until a probePath input is provided — an // unverified subject can never evaluate green). The monorepo subject is this // repository itself: its rails are the husky hooks + shared lint/format // configs, covered by QC-19; the downstream planted-commit probe does not // apply to it (this repo's own commit gates are QC-13/QC-14, outside this // evaluator's owned checks). const checkSetPolicySpec: CheckSetPolicySpec = { version: '1.0.0', byKind: { node: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'], python: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'], rust: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'], unknown: ['qc-19-rails-files-present', 'qc-20-enforcement-verify'], monorepo: ['qc-19-rails-files-present'], }, }; export const CHECK_SET_POLICY: CheckSetPolicy = { ...checkSetPolicySpec, policyDigest: digestOfPolicy(checkSetPolicySpec), }; export const QC_19_RAILS_FILES_PRESENT = defineCheck(qc19Spec, evaluateQc19); export const QC_20_ENFORCEMENT_VERIFY = defineCheck(qc20Spec, evaluateQc20); /** Built-in check definitions, keyed by id. */ export function builtInDefinitions(): CheckDefinition[] { return [QC_19_RAILS_FILES_PRESENT, QC_20_ENFORCEMENT_VERIFY]; } export function checkSetForKind( kind: SubjectKind, policy: CheckSetPolicy = CHECK_SET_POLICY, ): readonly string[] { const selected = policy.byKind[kind]; if (selected === undefined) { // Fail-closed selection: an unknown kind yields an EMPTY set only to the // caller; the runner treats an empty result list as `blocked`, never green. return []; } return selected; }