chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# @mosaicstack/quality-rails
|
||||
|
||||
Quality-rails scaffolding and — since RI-3-002 (RI-N4) — the **typed
|
||||
quality-rails evaluator**: the single authoritative producer of check verdicts
|
||||
for the checks it owns.
|
||||
|
||||
## Evaluator (RI-N4)
|
||||
|
||||
Every verdict is typed and fail-closed:
|
||||
|
||||
```
|
||||
{ status: 'passed' | 'failed' | 'blocked' | 'error' | 'not-applicable',
|
||||
checkId, checkVersion, subject, reason }
|
||||
```
|
||||
|
||||
Missing implementations, missing inputs, unknown check ids, process errors,
|
||||
timeouts, and malformed probe output can never become `passed` or an
|
||||
unqualified skip — they surface as `blocked`/`error` with a reason (vocabulary
|
||||
mirrors MACP's `GateStatus` discipline).
|
||||
|
||||
- Check definitions live as **data** with a version and a sha256 content
|
||||
digest (`definitionDigest`); every recorded verdict names the definition
|
||||
version that produced it.
|
||||
- Check sets are selected **per subject kind** (`node`, `python`, `rust`,
|
||||
`monorepo`, `unknown`) via the versioned, digested check-set policy — this
|
||||
repository (a `monorepo` subject) does not share the node template's file
|
||||
list.
|
||||
- Shell probes stay **thin adapters**: the TS evaluator invokes them and owns
|
||||
the verdict parsing (e.g. QC-20's planted-commit probe).
|
||||
|
||||
### Owned checks
|
||||
|
||||
| check id | canonical check | mechanism |
|
||||
| --------------------------- | --------------- | ---------------------------------------------------------------------- |
|
||||
| `qc-19-rails-files-present` | QC-19 | typed absorption of the former presence-only `check`/`doctor` loop |
|
||||
| `qc-20-enforcement-verify` | QC-20 | thin shell adapter (framework `verify.sh`); verdict parsing owned here |
|
||||
|
||||
The canonical check ids QC-1..QC-21 and their dispositions are defined in
|
||||
`docs/release-integrity/probe-inventory.md` (the RI-3-001 inventory — the
|
||||
evaluator's input, not its output).
|
||||
|
||||
### Usage
|
||||
|
||||
```sh
|
||||
# CLI (same typed report as the programmatic API)
|
||||
node dist/cli.js quality-rails evaluate --project <path> [--probe-path <verify.sh>] [--json]
|
||||
node dist/cli.js quality-rails check --project <path> [--json] # QC-19 only, fail-closed exit
|
||||
```
|
||||
|
||||
```ts
|
||||
import { evaluateSubject } from '@mosaicstack/quality-rails';
|
||||
|
||||
const report = await evaluateSubject({ subjectPath: '/path/to/project' });
|
||||
// report.state: 'passed' | 'failed' | 'blocked' | 'error'
|
||||
```
|
||||
|
||||
`pnpm verify:release` invokes this evaluator as its `quality-rails` stage
|
||||
(canonical-only stage, QC-19 on the monorepo subject).
|
||||
|
||||
## Scaffolding (pre-existing)
|
||||
|
||||
`init` scaffolds rails files per detected kind/profile; `doctor` is advisory
|
||||
and reports typed states.
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@mosaicstack/quality-rails",
|
||||
"version": "0.0.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"directory": "packages/quality-rails"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { mkdir, mkdtemp, writeFile, chmod } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createQualityRailsCli } from './cli.js';
|
||||
import { QC_19_RAILS_FILES_PRESENT } from './evaluator/definitions.js';
|
||||
import { evaluateSubject } from './evaluator/runner.js';
|
||||
import type { EvaluationReport } from './evaluator/types.js';
|
||||
|
||||
// CLI ↔ programmatic contract (RI-3-002): the same subject must produce the
|
||||
// same typed verdicts through every entry point the card adds — the
|
||||
// `evaluate`/`check` CLI surfaces and the `evaluateSubject` API.
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'qr-cli-'));
|
||||
}
|
||||
|
||||
async function scaffoldNodeFixture(skip: string[] = []): Promise<string> {
|
||||
const dir = await makeTempDir();
|
||||
await writeFile(join(dir, 'package.json'), '{}\n', 'utf8');
|
||||
for (const relativePath of [
|
||||
'.eslintrc',
|
||||
'biome.json',
|
||||
'.githooks/pre-commit',
|
||||
'PR-CHECKLIST.md',
|
||||
]) {
|
||||
if (skip.includes(relativePath)) continue;
|
||||
await mkdir(join(dir, relativePath, '..'), { recursive: true });
|
||||
await writeFile(join(dir, relativePath), 'fixture\n', 'utf8');
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function makePassingProbe(dir: string): Promise<string> {
|
||||
const scriptPath = join(dir, 'probe-pass.sh');
|
||||
await writeFile(
|
||||
scriptPath,
|
||||
[
|
||||
'#!/bin/bash',
|
||||
'echo "✅ PASS: Type errors blocked"',
|
||||
'echo "✅ PASS: Lint errors blocked"',
|
||||
'echo "Verification Summary"',
|
||||
'exit 0',
|
||||
].join('\n') + '\n',
|
||||
'utf8',
|
||||
);
|
||||
await chmod(scriptPath, 0o755);
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
describe('CLI entry points vs the programmatic evaluator', () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let previousExitCode: string | number | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
previousExitCode = process.exitCode ?? undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
process.exitCode = previousExitCode;
|
||||
});
|
||||
|
||||
it('evaluate --json produces the SAME typed report as evaluateSubject (full check set + probe)', async () => {
|
||||
const dir = await scaffoldNodeFixture();
|
||||
const probePath = await makePassingProbe(dir);
|
||||
|
||||
const programmatic = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
inputs: { 'qc-20-enforcement-verify': { probePath } },
|
||||
});
|
||||
|
||||
const program = createQualityRailsCli();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'cli.js',
|
||||
'quality-rails',
|
||||
'evaluate',
|
||||
'--project',
|
||||
dir,
|
||||
'--probe-path',
|
||||
probePath,
|
||||
'--json',
|
||||
]);
|
||||
|
||||
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
const cliReport = JSON.parse(printed) as EvaluationReport;
|
||||
expect(cliReport).toEqual(programmatic);
|
||||
expect(cliReport.state).toBe('passed');
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('check --json produces the SAME QC-19 verdict as evaluateSubject (absorbed loop)', async () => {
|
||||
const dir = await scaffoldNodeFixture(['biome.json', '.githooks/pre-commit']);
|
||||
|
||||
const programmatic = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
|
||||
});
|
||||
expect(programmatic.state).toBe('failed');
|
||||
|
||||
const program = createQualityRailsCli();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'cli.js',
|
||||
'quality-rails',
|
||||
'check',
|
||||
'--project',
|
||||
dir,
|
||||
'--json',
|
||||
]);
|
||||
|
||||
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
const cliReport = JSON.parse(printed) as EvaluationReport;
|
||||
expect(cliReport).toEqual(programmatic);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('check on a complete subject exits 0 with a passed verdict', async () => {
|
||||
const dir = await scaffoldNodeFixture();
|
||||
const program = createQualityRailsCli();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'cli.js',
|
||||
'quality-rails',
|
||||
'check',
|
||||
'--project',
|
||||
dir,
|
||||
'--json',
|
||||
]);
|
||||
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
const cliReport = JSON.parse(printed) as EvaluationReport;
|
||||
expect(cliReport.state).toBe('passed');
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('evaluate with an unknown check id exits 1 and reports error, never passed', async () => {
|
||||
const dir = await scaffoldNodeFixture();
|
||||
const program = createQualityRailsCli();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'cli.js',
|
||||
'quality-rails',
|
||||
'evaluate',
|
||||
'--project',
|
||||
dir,
|
||||
'--check',
|
||||
'qc-99-bogus',
|
||||
'--json',
|
||||
]);
|
||||
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
const cliReport = JSON.parse(printed) as EvaluationReport;
|
||||
expect(cliReport.results).toHaveLength(1);
|
||||
const first = cliReport.results[0];
|
||||
expect(first?.status).toBe('error');
|
||||
expect(first?.reason).toContain('unknown check id');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('evaluate on a scaffold subject without --probe-path stays fail-closed (blocked, exit 1)', async () => {
|
||||
const dir = await scaffoldNodeFixture();
|
||||
const program = createQualityRailsCli();
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'cli.js',
|
||||
'quality-rails',
|
||||
'evaluate',
|
||||
'--project',
|
||||
dir,
|
||||
'--json',
|
||||
]);
|
||||
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
const cliReport = JSON.parse(printed) as EvaluationReport;
|
||||
const qc20 = cliReport.results.find((r) => r.checkId === 'qc-20-enforcement-verify');
|
||||
expect(qc20).toBeDefined();
|
||||
expect(qc20?.status).toBe('blocked');
|
||||
expect(qc20?.reason).toContain('probePath');
|
||||
expect(cliReport.state).toBe('blocked');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('doctor stays advisory (no nonzero exit) but reports TYPED states, including blocked', async () => {
|
||||
const dir = await scaffoldNodeFixture();
|
||||
const program = createQualityRailsCli();
|
||||
await program.parseAsync(['node', 'cli.js', 'quality-rails', 'doctor', '--project', dir]);
|
||||
|
||||
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
expect(printed).toContain('blocked: qc-20-enforcement-verify');
|
||||
expect(process.exitCode ?? 0).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { detectProjectKind } from './detect.js';
|
||||
import { scaffoldQualityRails } from './scaffolder.js';
|
||||
import type { ProjectKind, QualityProfile, RailsConfig } from './types.js';
|
||||
import { QC_19_RAILS_FILES_PRESENT } from './evaluator/definitions.js';
|
||||
import { evaluateSubject } from './evaluator/runner.js';
|
||||
import type { EvaluationReport } from './evaluator/types.js';
|
||||
|
||||
const VALID_PROFILES: readonly QualityProfile[] = ['strict', 'standard', 'minimal'];
|
||||
|
||||
function parseProfile(rawProfile: string): QualityProfile {
|
||||
if (VALID_PROFILES.includes(rawProfile as QualityProfile)) {
|
||||
return rawProfile as QualityProfile;
|
||||
}
|
||||
throw new Error(`Invalid profile: ${rawProfile}. Use one of ${VALID_PROFILES.join(', ')}.`);
|
||||
}
|
||||
|
||||
function defaultLinters(kind: ProjectKind): string[] {
|
||||
if (kind === 'node') {
|
||||
return ['eslint', 'biome'];
|
||||
}
|
||||
|
||||
if (kind === 'python') {
|
||||
return ['ruff'];
|
||||
}
|
||||
|
||||
if (kind === 'rust') {
|
||||
return ['clippy'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function defaultFormatters(kind: ProjectKind): string[] {
|
||||
if (kind === 'node') {
|
||||
return ['prettier'];
|
||||
}
|
||||
|
||||
if (kind === 'python') {
|
||||
return ['black'];
|
||||
}
|
||||
|
||||
if (kind === 'rust') {
|
||||
return ['rustfmt'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function printScaffoldResult(
|
||||
config: RailsConfig,
|
||||
filesWritten: string[],
|
||||
warnings: string[],
|
||||
commandsToRun: string[],
|
||||
): void {
|
||||
console.log(`[quality-rails] initialized at ${config.projectPath}`);
|
||||
console.log(`kind=${config.kind} profile=${config.profile}`);
|
||||
|
||||
if (filesWritten.length > 0) {
|
||||
console.log('files written:');
|
||||
for (const filePath of filesWritten) {
|
||||
console.log(` - ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (commandsToRun.length > 0) {
|
||||
console.log('run next:');
|
||||
for (const command of commandsToRun) {
|
||||
console.log(` - ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.log('warnings:');
|
||||
for (const warning of warnings) {
|
||||
console.log(` - ${warning}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printEvaluationReport(report: EvaluationReport): void {
|
||||
console.log(
|
||||
`[quality-rails] evaluation for ${report.subject.path} (kind=${report.subject.kind}, check-set v${report.checkSetVersion})`,
|
||||
);
|
||||
for (const result of report.results) {
|
||||
const reason = result.reason === undefined ? '' : ` — ${result.reason}`;
|
||||
const digest = report.definitionDigests[result.checkId] ?? 'no digest';
|
||||
console.log(
|
||||
` - ${result.status}: ${result.checkId} (v${result.checkVersion} [${digest}])${reason}`,
|
||||
);
|
||||
}
|
||||
console.log(`[quality-rails] aggregate: ${report.state}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register quality-rails subcommands on an existing Commander program.
|
||||
* This avoids cross-package Commander version mismatches by using the
|
||||
* caller's Command instance directly.
|
||||
*/
|
||||
export function registerQualityRails(parent: Command): void {
|
||||
buildQualityRailsCommand(
|
||||
parent.command('quality-rails').description('Manage quality rails scaffolding'),
|
||||
);
|
||||
}
|
||||
|
||||
export function createQualityRailsCli(): Command {
|
||||
const program = new Command('mosaic');
|
||||
buildQualityRailsCommand(
|
||||
program.command('quality-rails').description('Manage quality rails scaffolding'),
|
||||
);
|
||||
return program;
|
||||
}
|
||||
|
||||
function buildQualityRailsCommand(qualityRails: Command): void {
|
||||
qualityRails
|
||||
.command('init')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--profile <profile>', 'strict|standard|minimal', 'standard')
|
||||
.action(async (options: { project: string; profile: string }) => {
|
||||
const profile = parseProfile(options.profile);
|
||||
const projectPath = resolve(options.project);
|
||||
const kind = await detectProjectKind(projectPath);
|
||||
|
||||
const config: RailsConfig = {
|
||||
projectPath,
|
||||
kind,
|
||||
profile,
|
||||
linters: defaultLinters(kind),
|
||||
formatters: defaultFormatters(kind),
|
||||
hooks: true,
|
||||
};
|
||||
|
||||
const result = await scaffoldQualityRails(config);
|
||||
printScaffoldResult(config, result.filesWritten, result.warnings, result.commandsToRun);
|
||||
});
|
||||
|
||||
// `check` (QC-19) is ABSORBED by the RI-N4 evaluator: the presence loop
|
||||
// that lived here is now the versioned, digested, typed check definition
|
||||
// `qc-19-rails-files-present`. The CLI keeps its human surface (missing
|
||||
// files listed, exit 1) and gains `--json` for the typed verdicts. Exit
|
||||
// code is fail-closed: any non-green aggregate (failed/blocked/error) is 1.
|
||||
qualityRails
|
||||
.command('check')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--json', 'print the typed evaluation report as JSON')
|
||||
.action(async (options: { project: string; json?: boolean }) => {
|
||||
const projectPath = resolve(options.project);
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: projectPath,
|
||||
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(report));
|
||||
} else {
|
||||
printEvaluationReport(report);
|
||||
}
|
||||
|
||||
process.exitCode = report.state === 'passed' ? 0 : 1;
|
||||
});
|
||||
|
||||
// `doctor` (QC-19) stays advisory (documented contract: a doctor that
|
||||
// cannot fail), but now reports TYPED states — a blocked or failing rail is
|
||||
// visible instead of silently printed as `ok`/`missing`.
|
||||
qualityRails
|
||||
.command('doctor')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.action(async (options: { project: string }) => {
|
||||
const projectPath = resolve(options.project);
|
||||
const report = await evaluateSubject({ subjectPath: projectPath });
|
||||
|
||||
console.log(`[quality-rails] doctor for ${projectPath}`);
|
||||
console.log(`detected project kind: ${report.subject.kind}`);
|
||||
for (const result of report.results) {
|
||||
const reason = result.reason === undefined ? '' : ` — ${result.reason}`;
|
||||
console.log(` - ${result.status}: ${result.checkId}${reason}`);
|
||||
}
|
||||
|
||||
if (report.subject.kind === 'unknown') {
|
||||
console.log(
|
||||
'recommendation: add package.json, pyproject.toml, or Cargo.toml for better defaults.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// `evaluate` is the canonical RI-N4 evaluator entry point: typed verdicts
|
||||
// for the subject's full per-kind check set, same results as the
|
||||
// programmatic API (evaluateSubject).
|
||||
qualityRails
|
||||
.command('evaluate')
|
||||
.description('Run the typed quality-rails evaluator against a subject project')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--check <id...>', 'restrict evaluation to these check ids')
|
||||
.option(
|
||||
'--probe-path <path>',
|
||||
'path to the QC-20 behavioral probe script (framework verify.sh)',
|
||||
)
|
||||
.option('--json', 'print the typed evaluation report as JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
project: string;
|
||||
check?: string[];
|
||||
probePath?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const projectPath = resolve(options.project);
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: projectPath,
|
||||
checkIds: options.check,
|
||||
inputs: options.probePath
|
||||
? { 'qc-20-enforcement-verify': { probePath: options.probePath } }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(report));
|
||||
} else {
|
||||
printEvaluationReport(report);
|
||||
}
|
||||
|
||||
process.exitCode = report.state === 'passed' ? 0 : 1;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function runQualityRailsCli(argv: string[] = process.argv): Promise<void> {
|
||||
const program = createQualityRailsCli();
|
||||
await program.parseAsync(argv);
|
||||
}
|
||||
|
||||
const entryPath = process.argv[1] ? resolve(process.argv[1]) : '';
|
||||
if (entryPath.length > 0 && entryPath === fileURLToPath(import.meta.url)) {
|
||||
runQualityRailsCli().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ProjectKind } from './types.js';
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectProjectKind(projectPath: string): Promise<ProjectKind> {
|
||||
if (await fileExists(join(projectPath, 'package.json'))) {
|
||||
return 'node';
|
||||
}
|
||||
|
||||
if (await fileExists(join(projectPath, 'pyproject.toml'))) {
|
||||
return 'python';
|
||||
}
|
||||
|
||||
if (await fileExists(join(projectPath, 'Cargo.toml'))) {
|
||||
return 'rust';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
|
||||
import type { AdapterOutcome, AdapterRequest, ProcessAdapter } from './types.js';
|
||||
|
||||
/**
|
||||
* Default thin process adapter (spawn-based). Runs a command to completion with
|
||||
* a hard timeout and reports exit code + captured output — it owns NO verdict
|
||||
* logic. Interpreting the outcome is always the check implementation's job.
|
||||
*/
|
||||
export function createSpawnProcessAdapter(): ProcessAdapter {
|
||||
return {
|
||||
run(request: AdapterRequest): Promise<AdapterOutcome> {
|
||||
return new Promise((resolve) => {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawn(request.file, request.args, {
|
||||
cwd: request.cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (error) {
|
||||
resolve({
|
||||
ok: false,
|
||||
kind: 'spawn-error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill('SIGKILL');
|
||||
resolve({
|
||||
ok: false,
|
||||
kind: 'timeout',
|
||||
message: `process timed out after ${request.timeoutMs}ms: ${request.file}`,
|
||||
});
|
||||
}, request.timeoutMs);
|
||||
|
||||
const settle = (outcome: AdapterOutcome): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(outcome);
|
||||
};
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', (error: Error) => {
|
||||
settle({ ok: false, kind: 'spawn-error', message: error.message });
|
||||
});
|
||||
child.on('close', (code: number | null) => {
|
||||
settle({ ok: true, exitCode: code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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<CheckOutcome>,
|
||||
): CheckDefinition {
|
||||
return { ...spec, definitionDigest: digestOfSpec(spec), evaluate };
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
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<CheckOutcome> {
|
||||
const byKind = ctx.params['expectedFilesByKind'] as Record<string, readonly string[]> | 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<CheckOutcome> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { CheckDefinitionSpec, CheckSetPolicySpec } from './types.js';
|
||||
|
||||
// Deterministic JSON: object keys sorted at every level so two specs with the
|
||||
// same content always produce the same bytes (and thus the same digest).
|
||||
export function canonicalJson(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
|
||||
}
|
||||
|
||||
/** sha256 over the canonical JSON of `value`. */
|
||||
export function digestContent(value: unknown): string {
|
||||
return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Content digest of a check definition: covers the declarative spec (id,
|
||||
* version, canonical check, description, applicability, params) — everything a
|
||||
* reviewer reasons about — while excluding the executable function object.
|
||||
* Changing any covered field changes the digest, so a recorded digest always
|
||||
* identifies exactly which definition content produced a verdict.
|
||||
*/
|
||||
export function digestOfSpec(spec: CheckDefinitionSpec): string {
|
||||
return digestContent({
|
||||
id: spec.id,
|
||||
version: spec.version,
|
||||
canonicalCheck: spec.canonicalCheck,
|
||||
description: spec.description,
|
||||
appliesTo: spec.appliesTo,
|
||||
params: spec.params,
|
||||
});
|
||||
}
|
||||
|
||||
/** Content digest of the per-subject check-set policy. */
|
||||
export function digestOfPolicy(spec: CheckSetPolicySpec): string {
|
||||
return digestContent(spec);
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
import { mkdtemp, mkdir, writeFile, chmod } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createSpawnProcessAdapter } from './adapter.js';
|
||||
import {
|
||||
builtInDefinitions,
|
||||
CHECK_SET_POLICY,
|
||||
checkSetForKind,
|
||||
defineCheck,
|
||||
QC_19_RAILS_FILES_PRESENT,
|
||||
QC_20_ENFORCEMENT_VERIFY,
|
||||
} from './definitions.js';
|
||||
import { digestOfSpec } from './digest.js';
|
||||
import { aggregateState, evaluateSubject } from './runner.js';
|
||||
import type {
|
||||
AdapterOutcome,
|
||||
CheckDefinitionSpec,
|
||||
CheckResult,
|
||||
EvaluationReport,
|
||||
ProcessAdapter,
|
||||
} from './types.js';
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function firstResult(report: EvaluationReport): CheckResult {
|
||||
const result = report.results[0];
|
||||
if (result === undefined) {
|
||||
throw new Error('expected the report to contain at least one result');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'qr-evaluator-'));
|
||||
}
|
||||
|
||||
async function writeProbeScript(dir: string, name: string, body: string): Promise<string> {
|
||||
const scriptPath = join(dir, name);
|
||||
await writeFile(scriptPath, `${body}\n`, 'utf8');
|
||||
await chmod(scriptPath, 0o755);
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
/** Adapter stub that always returns the given outcome (no real process). */
|
||||
function stubAdapter(outcome: AdapterOutcome): ProcessAdapter {
|
||||
return {
|
||||
run: async () => outcome,
|
||||
};
|
||||
}
|
||||
|
||||
// VERBATIM copy of the pre-absorption presence loop (former cli.ts
|
||||
// expectedFilesForKind + fileExists loop). This is the PARITY ORACLE: the
|
||||
// evaluator's typed QC-19 verdict must agree with what the absorbed check
|
||||
// concluded on the same fixture.
|
||||
const LEGACY_EXPECTED: Record<'node' | 'python' | 'rust' | 'unknown', string[]> = {
|
||||
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'],
|
||||
unknown: ['.githooks/pre-commit', 'PR-CHECKLIST.md'],
|
||||
};
|
||||
|
||||
async function legacyPresenceLoop(projectPath: string, kind: keyof typeof LEGACY_EXPECTED) {
|
||||
const missing: string[] = [];
|
||||
for (const relativePath of LEGACY_EXPECTED[kind]) {
|
||||
const fs = await import('node:fs/promises');
|
||||
try {
|
||||
await fs.access(join(projectPath, relativePath));
|
||||
} catch {
|
||||
missing.push(relativePath);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
async function scaffoldFixture(kind: keyof typeof LEGACY_EXPECTED, skip: string[] = []) {
|
||||
const dir = await makeTempDir();
|
||||
if (kind === 'node') {
|
||||
await writeFile(join(dir, 'package.json'), '{}\n', 'utf8');
|
||||
}
|
||||
if (kind === 'python') {
|
||||
await writeFile(join(dir, 'pyproject.toml'), '[project]\n', 'utf8');
|
||||
}
|
||||
if (kind === 'rust') {
|
||||
await writeFile(join(dir, 'Cargo.toml'), '[package]\n', 'utf8');
|
||||
}
|
||||
for (const relativePath of LEGACY_EXPECTED[kind]) {
|
||||
if (skip.includes(relativePath)) continue;
|
||||
await mkdir(join(dir, relativePath, '..'), { recursive: true });
|
||||
await writeFile(join(dir, relativePath), 'fixture\n', 'utf8');
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ─── QC-19 parity: typed verdict == absorbed presence loop ──────────────────
|
||||
|
||||
describe('QC-19 parity with the absorbed presence loop', () => {
|
||||
const kinds: Array<keyof typeof LEGACY_EXPECTED> = ['node', 'python', 'rust', 'unknown'];
|
||||
|
||||
it.each(kinds)('positive fixture (%s): loop said ok ⇒ evaluator passed', async (kind) => {
|
||||
const dir = await scaffoldFixture(kind);
|
||||
const oracleMissing = await legacyPresenceLoop(dir, kind);
|
||||
expect(oracleMissing).toEqual([]);
|
||||
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
|
||||
});
|
||||
const result = report.results.find((r) => r.checkId === QC_19_RAILS_FILES_PRESENT.id);
|
||||
expect(result?.status).toBe('passed');
|
||||
expect(result?.reason).toBeUndefined();
|
||||
expect(report.state).toBe('passed');
|
||||
});
|
||||
|
||||
it.each(kinds)(
|
||||
'negative fixture (%s): loop listed missing ⇒ evaluator failed with them',
|
||||
async (kind) => {
|
||||
const all = LEGACY_EXPECTED[kind];
|
||||
const skip = all.slice(0, Math.max(1, all.length - 1)); // leave exactly 1 present
|
||||
const dir = await scaffoldFixture(kind, skip);
|
||||
const oracleMissing = await legacyPresenceLoop(dir, kind);
|
||||
expect(oracleMissing.length).toBeGreaterThan(0);
|
||||
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
|
||||
});
|
||||
const result = report.results.find((r) => r.checkId === QC_19_RAILS_FILES_PRESENT.id);
|
||||
expect(result?.status).toBe('failed');
|
||||
expect(report.state).toBe('failed');
|
||||
for (const missingFile of oracleMissing) {
|
||||
expect(result?.reason).toContain(missingFile);
|
||||
}
|
||||
// No false attribution: a present file must not be named in the reason.
|
||||
const presentFile = all.find((file) => !skip.includes(file));
|
||||
if (presentFile !== undefined) {
|
||||
expect(result?.reason).not.toContain(` ${presentFile},`);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ─── per-subject check sets (inventory gap 7) ────────────────────────────────
|
||||
|
||||
describe('per-subject check sets', () => {
|
||||
it('monorepo subject selects only QC-19 with the monorepo file set', async () => {
|
||||
const dir = await makeTempDir();
|
||||
await writeFile(join(dir, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n', 'utf8');
|
||||
for (const file of [
|
||||
'.husky/pre-commit',
|
||||
'.husky/pre-push',
|
||||
'eslint.config.mjs',
|
||||
'.prettierrc',
|
||||
'.lintstagedrc',
|
||||
]) {
|
||||
await mkdir(join(dir, file, '..'), { recursive: true });
|
||||
await writeFile(join(dir, file), 'fixture\n', 'utf8');
|
||||
}
|
||||
|
||||
const report = await evaluateSubject({ subjectPath: dir });
|
||||
expect(report.subject.kind).toBe('monorepo');
|
||||
expect(report.results.map((r) => r.checkId)).toEqual(['qc-19-rails-files-present']);
|
||||
expect(report.state).toBe('passed');
|
||||
});
|
||||
|
||||
it('a monorepo missing one of its rails files fails QC-19 (not the node list)', async () => {
|
||||
const dir = await makeTempDir();
|
||||
await writeFile(join(dir, 'pnpm-workspace.yaml'), 'packages:\n', 'utf8');
|
||||
const report = await evaluateSubject({ subjectPath: dir });
|
||||
const result = report.results.find((r) => r.checkId === QC_19_RAILS_FILES_PRESENT.id);
|
||||
expect(result?.status).toBe('failed');
|
||||
expect(result?.reason).toContain('.husky/pre-commit');
|
||||
// The node-template list must NOT be applied to a monorepo subject.
|
||||
expect(result?.reason).not.toContain('biome.json');
|
||||
});
|
||||
|
||||
it('the policy selects the behavioral probe for scaffold kinds but not monorepo', () => {
|
||||
expect(checkSetForKind('node')).toContain(QC_20_ENFORCEMENT_VERIFY.id);
|
||||
expect(checkSetForKind('unknown')).toContain(QC_20_ENFORCEMENT_VERIFY.id);
|
||||
expect(checkSetForKind('monorepo')).not.toContain(QC_20_ENFORCEMENT_VERIFY.id);
|
||||
expect(CHECK_SET_POLICY.version).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── negative controls (the point of the card) ───────────────────────────────
|
||||
|
||||
describe('negative controls', () => {
|
||||
it('unknown check id ⇒ error, never passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: ['qc-99-does-not-exist'],
|
||||
});
|
||||
expect(report.results).toHaveLength(1);
|
||||
const result = firstResult(report);
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.reason).toContain("unknown check id 'qc-99-does-not-exist'");
|
||||
expect(result.status === 'passed').toBe(false);
|
||||
expect(report.state).toBe('error');
|
||||
});
|
||||
|
||||
it('missing subject (directory absent) ⇒ blocked for every check, never passed', async () => {
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: join(tmpdir(), `qr-evaluator-absent-${Date.now()}`),
|
||||
});
|
||||
expect(report.results.length).toBeGreaterThan(0);
|
||||
for (const result of report.results) {
|
||||
expect(result.status).toBe('blocked');
|
||||
expect(result.reason).toContain('subject directory does not exist');
|
||||
}
|
||||
expect(report.state).toBe('blocked');
|
||||
});
|
||||
|
||||
it('QC-20 without probePath input ⇒ blocked, never passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||
});
|
||||
const result = firstResult(report);
|
||||
expect(result.status).toBe('blocked');
|
||||
expect(result.reason).toContain('missing input: probePath');
|
||||
});
|
||||
|
||||
it('QC-20 with a nonexistent probe script ⇒ blocked, never passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||
inputs: { 'qc-20-enforcement-verify': { probePath: join(dir, 'no-such-probe.sh') } },
|
||||
});
|
||||
expect(firstResult(report).status).toBe('blocked');
|
||||
expect(firstResult(report).reason).toContain('probe script not found');
|
||||
});
|
||||
|
||||
it('adapter process error (spawn failure) ⇒ error, never passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||
inputs: { 'qc-20-enforcement-verify': { probePath: join(dir, 'PR-CHECKLIST.md') } },
|
||||
adapter: stubAdapter({ ok: false, kind: 'spawn-error', message: 'ENOENT bash' }),
|
||||
});
|
||||
expect(firstResult(report).status).toBe('error');
|
||||
expect(firstResult(report).reason).toContain('probe process spawn-error');
|
||||
expect(firstResult(report).status === 'passed').toBe(false);
|
||||
});
|
||||
|
||||
it('adapter timeout ⇒ error, never passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||
inputs: { 'qc-20-enforcement-verify': { probePath: join(dir, 'PR-CHECKLIST.md') } },
|
||||
adapter: stubAdapter({ ok: false, kind: 'timeout', message: 'timed out after 120000ms' }),
|
||||
});
|
||||
expect(firstResult(report).status).toBe('error');
|
||||
expect(firstResult(report).reason).toContain('probe process timeout');
|
||||
});
|
||||
|
||||
it('probe exit 1 with parseable FAIL markers ⇒ failed (interpretably red), never passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const probe = await writeProbeScript(
|
||||
dir,
|
||||
'probe-fail.sh',
|
||||
`echo "Test 1: ..."\necho "❌ FAIL: Type errors NOT blocked"\necho "Verification Summary"\nexit 1`,
|
||||
);
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||
adapter: createSpawnProcessAdapter(),
|
||||
});
|
||||
expect(firstResult(report).status).toBe('failed');
|
||||
expect(firstResult(report).reason).toContain('FAIL: Type errors NOT blocked');
|
||||
expect(report.state).toBe('failed');
|
||||
});
|
||||
|
||||
it('probe exit 1 WITHOUT parseable FAIL markers ⇒ malformed ⇒ error, never passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const probe = await writeProbeScript(dir, 'probe-mute.sh', `echo "nothing to see"\nexit 1`);
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||
adapter: createSpawnProcessAdapter(),
|
||||
});
|
||||
expect(firstResult(report).status).toBe('error');
|
||||
expect(firstResult(report).reason).toContain('malformed probe output');
|
||||
});
|
||||
|
||||
it('probe exit 0 without a parseable pass transcript ⇒ malformed ⇒ error, never passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const probe = await writeProbeScript(dir, 'probe-lie.sh', `echo "all good"\nexit 0`);
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||
adapter: createSpawnProcessAdapter(),
|
||||
});
|
||||
expect(firstResult(report).status).toBe('error');
|
||||
expect(firstResult(report).reason).toContain('malformed probe output');
|
||||
expect(firstResult(report).reason).toContain('exit 0');
|
||||
});
|
||||
|
||||
it('probe exit 0 WITH fail markers ⇒ contradictory transcript ⇒ error, never passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const probe = await writeProbeScript(
|
||||
dir,
|
||||
'probe-contradict.sh',
|
||||
`echo "✅ PASS: one"\necho "❌ FAIL: two"\necho "Verification Summary"\nexit 0`,
|
||||
);
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||
adapter: createSpawnProcessAdapter(),
|
||||
});
|
||||
expect(firstResult(report).status).toBe('error');
|
||||
});
|
||||
|
||||
it('probe unexpected exit code (7) ⇒ error, never passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const probe = await writeProbeScript(dir, 'probe-crash.sh', `echo "boom"\nexit 7`);
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||
adapter: createSpawnProcessAdapter(),
|
||||
});
|
||||
expect(firstResult(report).status).toBe('error');
|
||||
expect(firstResult(report).reason).toContain('unexpected code 7');
|
||||
});
|
||||
|
||||
it('check implementation throwing ⇒ error, never passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const spec: CheckDefinitionSpec = {
|
||||
id: 'test-throws',
|
||||
version: '1.0.0',
|
||||
canonicalCheck: 'QC-TEST',
|
||||
description: 'sabotage-shaped definition that always throws',
|
||||
appliesTo: ['node'],
|
||||
params: {},
|
||||
};
|
||||
const throwing = defineCheck(spec, async () => {
|
||||
throw new Error('kaboom');
|
||||
});
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: ['test-throws'],
|
||||
definitions: [throwing],
|
||||
});
|
||||
expect(firstResult(report).status).toBe('error');
|
||||
expect(firstResult(report).reason).toContain('kaboom');
|
||||
});
|
||||
|
||||
it('non-passed verdict without a reason ⇒ upgraded to error, never an unqualified skip', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const spec: CheckDefinitionSpec = {
|
||||
id: 'test-silent-fail',
|
||||
version: '1.0.0',
|
||||
canonicalCheck: 'QC-TEST',
|
||||
description: 'returns failed without a reason',
|
||||
appliesTo: ['node'],
|
||||
params: {},
|
||||
};
|
||||
const silent = defineCheck(spec, async () => ({ status: 'failed' }));
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: ['test-silent-fail'],
|
||||
definitions: [silent],
|
||||
});
|
||||
expect(firstResult(report).status).toBe('error');
|
||||
expect(firstResult(report).reason).toContain('without a reason');
|
||||
});
|
||||
|
||||
it('empty result list aggregates to blocked, never passed', () => {
|
||||
expect(aggregateState([])).toBe('blocked');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── QC-20 parity: typed verdict == shell probe's own conclusion ────────────
|
||||
|
||||
describe('QC-20 parity with the shell probe contract', () => {
|
||||
it('green transcript (exit 0) ⇒ evaluator passed', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const probe = await writeProbeScript(
|
||||
dir,
|
||||
'probe-pass.sh',
|
||||
[
|
||||
'echo "✅ PASS: Type errors blocked"',
|
||||
'echo "✅ PASS: any types blocked"',
|
||||
'echo "✅ PASS: Lint errors blocked"',
|
||||
'echo "Verification Summary"',
|
||||
'echo "✅ Passed: 3"',
|
||||
'exit 0',
|
||||
].join('\n'),
|
||||
);
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||
inputs: { 'qc-20-enforcement-verify': { probePath: probe } },
|
||||
adapter: createSpawnProcessAdapter(),
|
||||
});
|
||||
expect(firstResult(report).status).toBe('passed');
|
||||
expect(report.state).toBe('passed');
|
||||
});
|
||||
|
||||
it('the REAL framework verify.sh on a non-git subject concludes failed (exit 1) ⇒ evaluator failed', async () => {
|
||||
// Real-probe parity: verify.sh without a git repo cannot block planted
|
||||
// commits, exits 1 with FAIL markers — the evaluator must record exactly
|
||||
// `failed` with those markers, matching the probe's own conclusion.
|
||||
const realProbe = fileURLToPath(
|
||||
new URL('../../../mosaic/framework/tools/quality/scripts/verify.sh', import.meta.url),
|
||||
);
|
||||
const dir = await makeTempDir(); // not a git repository, no hooks
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_20_ENFORCEMENT_VERIFY.id],
|
||||
inputs: { 'qc-20-enforcement-verify': { probePath: realProbe } },
|
||||
adapter: createSpawnProcessAdapter(),
|
||||
});
|
||||
const result = firstResult(report);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.reason).toMatch(/FAIL:/);
|
||||
expect(report.state).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── version / digest discipline ─────────────────────────────────────────────
|
||||
|
||||
describe('versioned, digested check definitions', () => {
|
||||
it('every verdict records the definition version that produced it', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const report = await evaluateSubject({ subjectPath: dir });
|
||||
for (const result of report.results) {
|
||||
expect(result.checkVersion).toBe('1.0.0');
|
||||
}
|
||||
expect(report.checkSetVersion).toBe(CHECK_SET_POLICY.version);
|
||||
});
|
||||
|
||||
it('the report records each definition’s content digest', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const report = await evaluateSubject({ subjectPath: dir });
|
||||
expect(report.definitionDigests[QC_19_RAILS_FILES_PRESENT.id]).toBe(
|
||||
QC_19_RAILS_FILES_PRESENT.definitionDigest,
|
||||
);
|
||||
expect(report.definitionDigests[QC_20_ENFORCEMENT_VERIFY.id]).toBe(
|
||||
QC_20_ENFORCEMENT_VERIFY.definitionDigest,
|
||||
);
|
||||
});
|
||||
|
||||
it('digests are stable for identical content', () => {
|
||||
const spec = QC_19_RAILS_FILES_PRESENT;
|
||||
expect(digestOfSpec(spec)).toBe(digestOfSpec(spec));
|
||||
});
|
||||
|
||||
it('changing a definition’s content changes its digest', () => {
|
||||
const base = { ...QC_19_RAILS_FILES_PRESENT } as CheckDefinitionSpec;
|
||||
const baseDigest = digestOfSpec(base);
|
||||
|
||||
const changedParams: CheckDefinitionSpec = {
|
||||
...base,
|
||||
params: {
|
||||
expectedFilesByKind: {
|
||||
...(base.params['expectedFilesByKind'] as Record<string, string[]>),
|
||||
node: ['.eslintrc', 'biome.json', '.githooks/pre-commit', 'PR-CHECKLIST.md', 'NEW.md'],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(digestOfSpec(changedParams)).not.toBe(baseDigest);
|
||||
|
||||
const changedVersion: CheckDefinitionSpec = { ...base, version: '1.1.0' };
|
||||
expect(digestOfSpec(changedVersion)).not.toBe(baseDigest);
|
||||
});
|
||||
|
||||
it('a definition with changed content produces a different recorded digest and version', async () => {
|
||||
const dir = await scaffoldFixture('node');
|
||||
const modified = defineCheck(
|
||||
{ ...QC_19_RAILS_FILES_PRESENT, version: '2.0.0' } as unknown as CheckDefinitionSpec,
|
||||
async () => ({ status: 'passed' }),
|
||||
);
|
||||
const report = await evaluateSubject({
|
||||
subjectPath: dir,
|
||||
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
|
||||
definitions: [
|
||||
modified,
|
||||
...builtInDefinitions().filter((d) => d.id !== QC_19_RAILS_FILES_PRESENT.id),
|
||||
],
|
||||
});
|
||||
const result = firstResult(report);
|
||||
expect(result.checkVersion).toBe('2.0.0');
|
||||
expect(report.definitionDigests[QC_19_RAILS_FILES_PRESENT.id]).toBe(modified.definitionDigest);
|
||||
expect(modified.definitionDigest).not.toBe(QC_19_RAILS_FILES_PRESENT.definitionDigest);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── aggregate state ordering (MACP-style discipline) ───────────────────────
|
||||
|
||||
describe('aggregate state precedence', () => {
|
||||
const result = (status: 'passed' | 'failed' | 'blocked' | 'error') => ({
|
||||
status,
|
||||
checkId: 'x',
|
||||
checkVersion: '1.0.0',
|
||||
subject: '/tmp/x',
|
||||
});
|
||||
|
||||
it('all passed (with not-applicable) ⇒ passed', () => {
|
||||
expect(
|
||||
aggregateState([
|
||||
result('passed'),
|
||||
{ ...result('passed'), status: 'not-applicable' as const },
|
||||
]),
|
||||
).toBe('passed');
|
||||
});
|
||||
|
||||
it('error outranks blocked and failed; blocked outranks failed', () => {
|
||||
expect(aggregateState([result('blocked'), result('error')])).toBe('error');
|
||||
expect(aggregateState([result('failed'), result('blocked')])).toBe('blocked');
|
||||
expect(aggregateState([result('passed'), result('failed')])).toBe('failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Evaluator core types — RI-N4 (card RI-3-002, SDLC-D-037 second half).
|
||||
//
|
||||
// The quality-rails evaluator is the SOLE authoritative producer of check
|
||||
// verdicts for the checks it owns. Every verdict is typed and fail-closed:
|
||||
// missing implementations, missing inputs, unknown check ids, process errors,
|
||||
// timeouts, and malformed adapter output can never become `passed` or an
|
||||
// unqualified skip — they surface as `blocked` or `error` with a reason.
|
||||
// (Vocabulary mirrors MACP's GateStatus discipline from packages/macp.)
|
||||
|
||||
/**
|
||||
* Typed verdict for a single check execution.
|
||||
*
|
||||
* - `passed` — the check really ran and its condition held.
|
||||
* - `failed` — the check really ran and its condition did NOT hold.
|
||||
* - `blocked` — the check could not run at all (missing subject, missing
|
||||
* input). Never a green outcome.
|
||||
* - `error` — the check attempted to run but its outcome cannot be trusted
|
||||
* (unknown check id, implementation threw, process error, timeout, malformed
|
||||
* adapter output). Never a green outcome.
|
||||
* - `not-applicable` — the check definition explicitly declares it does not
|
||||
* apply to this subject (a qualified skip, always with a reason).
|
||||
*/
|
||||
export type CheckStatus = 'passed' | 'failed' | 'blocked' | 'error' | 'not-applicable';
|
||||
|
||||
/** Aggregate outcome, MACP-style: `passed` only when every result is green. */
|
||||
export type AggregateState = 'passed' | 'failed' | 'blocked' | 'error';
|
||||
|
||||
/** Kinds of subjects the evaluator can assess. */
|
||||
export type SubjectKind = 'node' | 'python' | 'rust' | 'monorepo' | 'unknown';
|
||||
|
||||
/**
|
||||
* A single check verdict. This is the canonical result shape: `status`,
|
||||
* `checkId`, `checkVersion`, `subject`, `reason`. `reason` is REQUIRED
|
||||
* (enforced by the runner) for every status other than `passed`.
|
||||
*/
|
||||
export interface CheckResult {
|
||||
status: CheckStatus;
|
||||
checkId: string;
|
||||
checkVersion: string;
|
||||
subject: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** The project being evaluated. */
|
||||
export interface Subject {
|
||||
/** Absolute path. */
|
||||
path: string;
|
||||
kind: SubjectKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The data half of a check definition. Definitions live as DATA with a version
|
||||
* and a content digest (see `digestOfSpec`); the executable half is attached
|
||||
* separately so the digest covers only reviewable, declarative content.
|
||||
*/
|
||||
export interface CheckDefinitionSpec {
|
||||
/** Stable id, e.g. `qc-19-rails-files-present`. */
|
||||
id: string;
|
||||
/** Semver of this definition's data+semantics. */
|
||||
version: string;
|
||||
/** Canonical check id from docs/release-integrity/probe-inventory.md (QC-n). */
|
||||
canonicalCheck: string;
|
||||
description: string;
|
||||
/** Subject kinds this check can assess (others yield `not-applicable`). */
|
||||
appliesTo: readonly SubjectKind[];
|
||||
/** Declarative parameters (file lists, markers, timeouts) — digest-covered. */
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A fully assembled check definition: spec + digest + implementation. */
|
||||
export interface CheckDefinition extends CheckDefinitionSpec {
|
||||
/** sha256 content digest of the spec (canonical JSON projection). */
|
||||
definitionDigest: string;
|
||||
evaluate(ctx: CheckContext): Promise<CheckOutcome>;
|
||||
}
|
||||
|
||||
/** What a check implementation returns; the runner stamps id/version/subject. */
|
||||
export interface CheckOutcome {
|
||||
status: CheckStatus;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Caller-provided inputs for one check invocation (e.g. the QC-20 probe path). */
|
||||
export type CheckInputs = Record<string, unknown>;
|
||||
|
||||
/** Everything a check implementation may use. */
|
||||
export interface CheckContext {
|
||||
subject: Subject;
|
||||
params: Record<string, unknown>;
|
||||
inputs: CheckInputs;
|
||||
adapter: ProcessAdapter;
|
||||
}
|
||||
|
||||
/** Outcome of running a shell probe through the thin process adapter. */
|
||||
export type AdapterOutcome =
|
||||
| { ok: true; exitCode: number | null; stdout: string; stderr: string }
|
||||
| { ok: false; kind: 'spawn-error' | 'timeout'; message: string };
|
||||
|
||||
/** Request for the process adapter. */
|
||||
export interface AdapterRequest {
|
||||
file: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin process adapter: runs a command, owns NO verdict logic. Verdict parsing
|
||||
* always lives in the check implementation (TS), never in the shell probe.
|
||||
*/
|
||||
export interface ProcessAdapter {
|
||||
run(request: AdapterRequest): Promise<AdapterOutcome>;
|
||||
}
|
||||
|
||||
/** Per-subject-kind check-set selection policy (versioned and digested). */
|
||||
export interface CheckSetPolicySpec {
|
||||
version: string;
|
||||
byKind: Record<SubjectKind, readonly string[]>;
|
||||
}
|
||||
|
||||
export interface CheckSetPolicy extends CheckSetPolicySpec {
|
||||
/** sha256 content digest of the policy spec. */
|
||||
policyDigest: string;
|
||||
}
|
||||
|
||||
/** Full typed evaluation report for one subject. */
|
||||
export interface EvaluationReport {
|
||||
subject: Subject;
|
||||
results: CheckResult[];
|
||||
/** checkId → content digest of the definition that produced the verdicts. */
|
||||
definitionDigests: Record<string, string>;
|
||||
/** Version of the check-set policy used for subject selection. */
|
||||
checkSetVersion: string;
|
||||
state: AggregateState;
|
||||
}
|
||||
|
||||
/** Options for `evaluateSubject`. */
|
||||
export interface EvaluateOptions {
|
||||
subjectPath: string;
|
||||
/** Restrict to these check ids; defaults to the subject kind's check set. */
|
||||
checkIds?: string[];
|
||||
/** Per-check inputs, keyed by check id (e.g. `{ 'qc-20-enforcement-verify': { probePath } }`). */
|
||||
inputs?: Record<string, CheckInputs>;
|
||||
/** Replace the built-in definitions (tests / future batches). */
|
||||
definitions?: CheckDefinition[];
|
||||
/** Inject a process adapter (tests / instrumentation). */
|
||||
adapter?: ProcessAdapter;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export * from './cli.js';
|
||||
export * from './detect.js';
|
||||
export * from './scaffolder.js';
|
||||
export * from './templates.js';
|
||||
export * from './types.js';
|
||||
|
||||
// RI-N4 evaluator (card RI-3-002): the public, programmatic entry points.
|
||||
export {
|
||||
builtInDefinitions,
|
||||
CHECK_SET_POLICY,
|
||||
checkSetForKind,
|
||||
defineCheck,
|
||||
QC_19_RAILS_FILES_PRESENT,
|
||||
QC_20_ENFORCEMENT_VERIFY,
|
||||
} from './evaluator/definitions.js';
|
||||
export { canonicalJson, digestContent, digestOfPolicy, digestOfSpec } from './evaluator/digest.js';
|
||||
export { createSpawnProcessAdapter } from './evaluator/adapter.js';
|
||||
export { aggregateState, detectSubjectKind, evaluateSubject } from './evaluator/runner.js';
|
||||
export type {
|
||||
AdapterOutcome,
|
||||
AdapterRequest,
|
||||
AggregateState,
|
||||
CheckContext,
|
||||
CheckDefinition,
|
||||
CheckDefinitionSpec,
|
||||
CheckInputs,
|
||||
CheckOutcome,
|
||||
CheckResult,
|
||||
CheckSetPolicy,
|
||||
CheckSetPolicySpec,
|
||||
CheckStatus,
|
||||
EvaluateOptions,
|
||||
EvaluationReport,
|
||||
ProcessAdapter,
|
||||
Subject,
|
||||
SubjectKind,
|
||||
} from './evaluator/types.js';
|
||||
@@ -0,0 +1,206 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import {
|
||||
biomeTemplate,
|
||||
eslintTemplate,
|
||||
prChecklistTemplate,
|
||||
preCommitHookTemplate,
|
||||
pyprojectSection,
|
||||
rustfmtTemplate,
|
||||
} from './templates.js';
|
||||
import type { RailsConfig, ScaffoldResult } from './types.js';
|
||||
|
||||
const PYPROJECT_START_MARKER = '# >>> mosaic-quality-rails >>>';
|
||||
const PYPROJECT_END_MARKER = '# <<< mosaic-quality-rails <<<';
|
||||
|
||||
async function ensureDirectory(filePath: string): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
async function writeRelativeFile(
|
||||
projectPath: string,
|
||||
relativePath: string,
|
||||
contents: string,
|
||||
result: ScaffoldResult,
|
||||
): Promise<void> {
|
||||
const absolutePath = join(projectPath, relativePath);
|
||||
await ensureDirectory(absolutePath);
|
||||
await writeFile(absolutePath, contents, { encoding: 'utf8', mode: 0o644 });
|
||||
result.filesWritten.push(relativePath);
|
||||
}
|
||||
|
||||
async function upsertPyproject(
|
||||
projectPath: string,
|
||||
profile: RailsConfig['profile'],
|
||||
result: ScaffoldResult,
|
||||
): Promise<void> {
|
||||
const pyprojectPath = join(projectPath, 'pyproject.toml');
|
||||
const nextSection = pyprojectSection(profile);
|
||||
|
||||
let previous = '';
|
||||
try {
|
||||
previous = await readFile(pyprojectPath, 'utf8');
|
||||
} catch {
|
||||
previous = '';
|
||||
}
|
||||
|
||||
const existingStart = previous.indexOf(PYPROJECT_START_MARKER);
|
||||
const existingEnd = previous.indexOf(PYPROJECT_END_MARKER);
|
||||
|
||||
if (existingStart >= 0 && existingEnd > existingStart) {
|
||||
const before = previous.slice(0, existingStart).trimEnd();
|
||||
const after = previous.slice(existingEnd + PYPROJECT_END_MARKER.length).trimStart();
|
||||
const rebuilt = [before, nextSection.trim(), after]
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join('\n\n');
|
||||
await writeRelativeFile(projectPath, 'pyproject.toml', `${rebuilt}\n`, result);
|
||||
return;
|
||||
}
|
||||
|
||||
const separator = previous.trim().length > 0 ? '\n\n' : '';
|
||||
await writeRelativeFile(
|
||||
projectPath,
|
||||
'pyproject.toml',
|
||||
`${previous.trimEnd()}${separator}${nextSection}`,
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: 'ignore',
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Command failed (${code ?? 'unknown'}): ${command} ${args.join(' ')}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildNodeDevDependencies(config: RailsConfig): string[] {
|
||||
const dependencies = new Set<string>();
|
||||
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'eslint')) {
|
||||
dependencies.add('eslint');
|
||||
dependencies.add('@typescript-eslint/parser');
|
||||
dependencies.add('@typescript-eslint/eslint-plugin');
|
||||
}
|
||||
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'biome')) {
|
||||
dependencies.add('@biomejs/biome');
|
||||
}
|
||||
|
||||
if (config.formatters.some((formatter) => formatter.toLowerCase() === 'prettier')) {
|
||||
dependencies.add('prettier');
|
||||
}
|
||||
|
||||
if (config.hooks) {
|
||||
dependencies.add('husky');
|
||||
}
|
||||
|
||||
return [...dependencies];
|
||||
}
|
||||
|
||||
async function installNodeDependencies(config: RailsConfig, result: ScaffoldResult): Promise<void> {
|
||||
const dependencies = buildNodeDevDependencies(config);
|
||||
if (dependencies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commandLine = `pnpm add -D ${dependencies.join(' ')}`;
|
||||
|
||||
if (process.env['MOSAIC_QUALITY_RAILS_SKIP_INSTALL'] === '1') {
|
||||
result.commandsToRun.push(commandLine);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runCommand('pnpm', ['add', '-D', ...dependencies], config.projectPath);
|
||||
} catch (error) {
|
||||
result.warnings.push(
|
||||
`Failed to auto-install Node dependencies: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
result.commandsToRun.push(commandLine);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scaffoldQualityRails(config: RailsConfig): Promise<ScaffoldResult> {
|
||||
const result: ScaffoldResult = {
|
||||
filesWritten: [],
|
||||
commandsToRun: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const normalizedLinters = new Set(config.linters.map((linter) => linter.toLowerCase()));
|
||||
|
||||
if (config.kind === 'node') {
|
||||
if (normalizedLinters.has('eslint')) {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'.eslintrc',
|
||||
eslintTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedLinters.has('biome')) {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'biome.json',
|
||||
biomeTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
await installNodeDependencies(config, result);
|
||||
}
|
||||
|
||||
if (config.kind === 'python') {
|
||||
await upsertPyproject(config.projectPath, config.profile, result);
|
||||
}
|
||||
|
||||
if (config.kind === 'rust') {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'rustfmt.toml',
|
||||
rustfmtTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.hooks) {
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'.githooks/pre-commit',
|
||||
preCommitHookTemplate(config),
|
||||
result,
|
||||
);
|
||||
await chmod(join(config.projectPath, '.githooks/pre-commit'), 0o755);
|
||||
result.commandsToRun.push('git config core.hooksPath .githooks');
|
||||
}
|
||||
|
||||
await writeRelativeFile(
|
||||
config.projectPath,
|
||||
'PR-CHECKLIST.md',
|
||||
prChecklistTemplate(config.profile),
|
||||
result,
|
||||
);
|
||||
|
||||
if (config.kind === 'unknown') {
|
||||
result.warnings.push(
|
||||
'Unable to detect project kind. Generated generic rails only (hooks + PR checklist).',
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { QualityProfile, RailsConfig } from './types.js';
|
||||
|
||||
const PROFILE_TO_MAX_WARNINGS: Record<QualityProfile, number> = {
|
||||
strict: 0,
|
||||
standard: 10,
|
||||
minimal: 50,
|
||||
};
|
||||
|
||||
const PROFILE_TO_LINE_LENGTH: Record<QualityProfile, number> = {
|
||||
strict: 100,
|
||||
standard: 110,
|
||||
minimal: 120,
|
||||
};
|
||||
|
||||
export function eslintTemplate(profile: QualityProfile): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
env: {
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'no-console': profile === 'strict' ? 'error' : 'warn',
|
||||
'@typescript-eslint/no-explicit-any':
|
||||
profile === 'minimal' ? 'off' : profile === 'strict' ? 'error' : 'warn',
|
||||
'@typescript-eslint/explicit-function-return-type': profile === 'strict' ? 'warn' : 'off',
|
||||
'max-lines-per-function': [
|
||||
profile === 'minimal' ? 'off' : 'warn',
|
||||
{
|
||||
max: profile === 'strict' ? 60 : 100,
|
||||
skipBlankLines: true,
|
||||
skipComments: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function biomeTemplate(profile: QualityProfile): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
$schema: 'https://biomejs.dev/schemas/1.8.3/schema.json',
|
||||
formatter: {
|
||||
enabled: true,
|
||||
indentStyle: 'space',
|
||||
indentWidth: 2,
|
||||
lineWidth: PROFILE_TO_LINE_LENGTH[profile],
|
||||
},
|
||||
linter: {
|
||||
enabled: true,
|
||||
rules: {
|
||||
recommended: true,
|
||||
suspicious: {
|
||||
noConsole: profile === 'strict' ? 'error' : 'warn',
|
||||
},
|
||||
complexity: {
|
||||
noExcessiveCognitiveComplexity:
|
||||
profile === 'strict' ? 'warn' : profile === 'standard' ? 'info' : 'off',
|
||||
},
|
||||
},
|
||||
},
|
||||
javascript: {
|
||||
formatter: {
|
||||
quoteStyle: 'single',
|
||||
trailingCommas: 'all',
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function pyprojectSection(profile: QualityProfile): string {
|
||||
const lineLength = PROFILE_TO_LINE_LENGTH[profile];
|
||||
return [
|
||||
'# >>> mosaic-quality-rails >>>',
|
||||
'[tool.ruff]',
|
||||
`line-length = ${lineLength}`,
|
||||
'target-version = "py311"',
|
||||
'',
|
||||
'[tool.ruff.lint]',
|
||||
'select = ["E", "F", "I", "UP", "B"]',
|
||||
`ignore = ${profile === 'minimal' ? '[]' : '["E501"]'}`,
|
||||
'',
|
||||
'[tool.black]',
|
||||
`line-length = ${lineLength}`,
|
||||
'',
|
||||
'# <<< mosaic-quality-rails <<<',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function rustfmtTemplate(profile: QualityProfile): string {
|
||||
const maxWidth = PROFILE_TO_LINE_LENGTH[profile];
|
||||
const useSmallHeuristics = profile === 'strict' ? 'Max' : 'Default';
|
||||
|
||||
return [
|
||||
`max_width = ${maxWidth}`,
|
||||
`use_small_heuristics = "${useSmallHeuristics}"`,
|
||||
`imports_granularity = "${profile === 'minimal' ? 'Crate' : 'Module'}"`,
|
||||
`group_imports = "${profile === 'strict' ? 'StdExternalCrate' : 'Preserve'}"`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function resolveHookCommands(config: RailsConfig): string[] {
|
||||
const commands: string[] = [];
|
||||
|
||||
if (config.kind === 'node') {
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'eslint')) {
|
||||
commands.push('pnpm lint');
|
||||
}
|
||||
if (config.linters.some((linter) => linter.toLowerCase() === 'biome')) {
|
||||
commands.push('pnpm biome check .');
|
||||
}
|
||||
if (config.formatters.some((formatter) => formatter.toLowerCase() === 'prettier')) {
|
||||
commands.push('pnpm prettier --check .');
|
||||
}
|
||||
commands.push('pnpm test --if-present');
|
||||
}
|
||||
|
||||
if (config.kind === 'python') {
|
||||
commands.push('ruff check .');
|
||||
commands.push('black --check .');
|
||||
}
|
||||
|
||||
if (config.kind === 'rust') {
|
||||
commands.push('cargo fmt --check');
|
||||
commands.push('cargo clippy --all-targets --all-features -- -D warnings');
|
||||
}
|
||||
|
||||
if (commands.length === 0) {
|
||||
commands.push('echo "No quality commands configured for this project kind"');
|
||||
}
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
export function preCommitHookTemplate(config: RailsConfig): string {
|
||||
const commands = resolveHookCommands(config)
|
||||
.map((command) => `${command} || exit 1`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'#!/usr/bin/env sh',
|
||||
'set -eu',
|
||||
'',
|
||||
'echo "[quality-rails] Running pre-commit checks..."',
|
||||
commands,
|
||||
'echo "[quality-rails] Checks passed."',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function prChecklistTemplate(profile: QualityProfile): string {
|
||||
return [
|
||||
'# Code Review Checklist',
|
||||
'',
|
||||
`Profile: **${profile}**`,
|
||||
'',
|
||||
'- [ ] Requirements mapped to tests',
|
||||
'- [ ] Error handling covers unhappy paths',
|
||||
'- [ ] Lint and typecheck are clean',
|
||||
'- [ ] Test suite passes',
|
||||
'- [ ] Security-sensitive paths reviewed',
|
||||
`- [ ] Warnings count <= ${PROFILE_TO_MAX_WARNINGS[profile]}`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type ProjectKind = 'node' | 'python' | 'rust' | 'unknown';
|
||||
|
||||
export type QualityProfile = 'strict' | 'standard' | 'minimal';
|
||||
|
||||
export interface RailsConfig {
|
||||
projectPath: string;
|
||||
kind: ProjectKind;
|
||||
profile: QualityProfile;
|
||||
linters: string[];
|
||||
formatters: string[];
|
||||
hooks: boolean;
|
||||
}
|
||||
|
||||
export interface ScaffoldResult {
|
||||
filesWritten: string[];
|
||||
commandsToRun: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user