155 lines
4.9 KiB
JavaScript
155 lines
4.9 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
const FIXTURE_ID_PATTERN = /^PRC-[A-Z]+-\d{3}$/;
|
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/i;
|
|
const FORBIDDEN_KEYS = new Set([
|
|
'raw_chat',
|
|
'raw_message',
|
|
'raw_memory',
|
|
'attachment_content',
|
|
'email',
|
|
'phone',
|
|
'openid',
|
|
'cookie',
|
|
'token',
|
|
'secret',
|
|
'user_id',
|
|
'workspace_path',
|
|
'production_url',
|
|
]);
|
|
|
|
function collectForbiddenKeys(value, prefix = '') {
|
|
if (!value || typeof value !== 'object') return [];
|
|
const found = [];
|
|
for (const [key, child] of Object.entries(value)) {
|
|
const field = prefix ? `${prefix}.${key}` : key;
|
|
if (FORBIDDEN_KEYS.has(key.toLowerCase())) found.push(field);
|
|
found.push(...collectForbiddenKeys(child, field));
|
|
}
|
|
return found;
|
|
}
|
|
|
|
export function validateRegressionFixture(fixture, {
|
|
catalogIds,
|
|
} = {}) {
|
|
const errors = [];
|
|
if (!FIXTURE_ID_PATTERN.test(fixture?.id ?? '')) errors.push('id must match PRC-<GROUP>-NNN');
|
|
if (!Array.isArray(fixture?.scenario_ids) || fixture.scenario_ids.length === 0) {
|
|
errors.push('scenario_ids must be a non-empty array');
|
|
} else if (catalogIds) {
|
|
for (const scenarioId of fixture.scenario_ids) {
|
|
if (!catalogIds.has(scenarioId)) errors.push(`unknown scenario_id ${scenarioId}`);
|
|
}
|
|
}
|
|
for (const field of ['failure_signature', 'synthetic_intent', 'source_batch_id']) {
|
|
if (!String(fixture?.[field] ?? '').trim()) errors.push(`${field} is required`);
|
|
}
|
|
if (!SHA256_PATTERN.test(fixture?.evidence_digest ?? '')) {
|
|
errors.push('evidence_digest must be an irreversible SHA256');
|
|
}
|
|
if (fixture?.contains_personal_data !== false) {
|
|
errors.push('contains_personal_data must be false');
|
|
}
|
|
if (!fixture?.privacy_review
|
|
|| !String(fixture.privacy_review.reviewed_by ?? '').trim()
|
|
|| Number.isNaN(Date.parse(fixture.privacy_review.reviewed_at ?? ''))) {
|
|
errors.push('privacy_review with reviewer and timestamp is required');
|
|
}
|
|
if (!fixture?.automation
|
|
|| !Array.isArray(fixture.automation.command)
|
|
|| fixture.automation.command.length < 2) {
|
|
errors.push('automation.command must be executable');
|
|
}
|
|
const forbidden = collectForbiddenKeys(fixture);
|
|
if (forbidden.length) errors.push(`forbidden personal-data fields: ${forbidden.join(',')}`);
|
|
return { valid: errors.length === 0, errors };
|
|
}
|
|
|
|
export async function loadActiveRegressionCorpus({
|
|
root,
|
|
catalog,
|
|
} = {}) {
|
|
const corpusRoot = path.join(root, 'scenarios', 'production-regressions');
|
|
const manifestPath = path.join(corpusRoot, 'manifests', 'active.json');
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
return {
|
|
status: 'missing',
|
|
manifestPath,
|
|
fixtures: [],
|
|
errors: ['active production regression manifest is missing'],
|
|
};
|
|
}
|
|
return {
|
|
status: 'invalid',
|
|
manifestPath,
|
|
fixtures: [],
|
|
errors: [`active production regression manifest is invalid: ${error.message}`],
|
|
};
|
|
}
|
|
|
|
const fixtureIds = manifest?.fixture_ids;
|
|
if (!Array.isArray(fixtureIds) || fixtureIds.length === 0) {
|
|
return {
|
|
status: 'invalid',
|
|
manifestPath,
|
|
fixtures: [],
|
|
errors: ['active manifest must contain at least one fixture_id'],
|
|
};
|
|
}
|
|
if (new Set(fixtureIds).size !== fixtureIds.length) {
|
|
return {
|
|
status: 'invalid',
|
|
manifestPath,
|
|
fixtures: [],
|
|
errors: ['active manifest contains duplicate fixture_ids'],
|
|
};
|
|
}
|
|
|
|
const fixtures = [];
|
|
const errors = [];
|
|
const catalogIds = new Set(catalog.map((scenario) => scenario.id));
|
|
for (const fixtureId of fixtureIds) {
|
|
if (!FIXTURE_ID_PATTERN.test(fixtureId)) {
|
|
errors.push(`invalid fixture id ${fixtureId}`);
|
|
continue;
|
|
}
|
|
const matches = [];
|
|
for (const bucket of ['agent-run', 'page-data', 'memory', 'wechat', 'files']) {
|
|
const candidate = path.join(corpusRoot, bucket, `${fixtureId}.json`);
|
|
try {
|
|
await fs.access(candidate);
|
|
matches.push(candidate);
|
|
} catch {
|
|
// The fixture belongs to exactly one approved regression bucket.
|
|
}
|
|
}
|
|
if (matches.length !== 1) {
|
|
errors.push(`${fixtureId} must exist in exactly one regression bucket`);
|
|
continue;
|
|
}
|
|
try {
|
|
const fixture = JSON.parse(await fs.readFile(matches[0], 'utf8'));
|
|
const validation = validateRegressionFixture(fixture, { catalogIds });
|
|
if (fixture.id !== fixtureId) validation.errors.push(`fixture id does not match ${fixtureId}`);
|
|
if (validation.errors.length) {
|
|
errors.push(`${fixtureId}: ${validation.errors.join('; ')}`);
|
|
} else {
|
|
fixtures.push({ ...fixture, file: path.relative(root, matches[0]) });
|
|
}
|
|
} catch (error) {
|
|
errors.push(`${fixtureId}: invalid JSON (${error.message})`);
|
|
}
|
|
}
|
|
return {
|
|
status: errors.length ? 'invalid' : 'ready',
|
|
manifestPath,
|
|
fixtures,
|
|
errors,
|
|
};
|
|
}
|