mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
160 lines
6.4 KiB
JavaScript
160 lines
6.4 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
export const CONCEPT_STATUSES = new Set(['approved', 'rejected']);
|
|
|
|
export function normalizeConceptForm(value) {
|
|
return String(value || '')
|
|
.normalize('NFKD')
|
|
.toLowerCase()
|
|
.replace(/[’‘]/g, "'")
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
export function readConceptCatalog(catalogPath, reviewsPath) {
|
|
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
|
|
const reviewData = JSON.parse(readFileSync(reviewsPath, 'utf8'));
|
|
const reviews = reviewData.reviews || {};
|
|
const concepts = [];
|
|
|
|
for (const family of catalog.families || []) {
|
|
for (const concept of family.concepts || []) {
|
|
concepts.push({
|
|
...concept,
|
|
familyId: family.id,
|
|
familyLabel: family.label,
|
|
status: reviews[concept.id]?.status || 'pending',
|
|
review: reviews[concept.id] || null,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { catalog, reviewData, reviews, concepts };
|
|
}
|
|
|
|
export function validateConceptCatalog(catalog, reviewData, { expectedTotal, minimumTotal } = {}) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
const familyIds = new Set();
|
|
const conceptIds = new Set();
|
|
const normalizedForms = new Map();
|
|
const concepts = [];
|
|
|
|
if (!Number.isInteger(catalog?.schemaVersion) || catalog.schemaVersion < 1) {
|
|
errors.push('catalog.schemaVersion must be a positive integer');
|
|
}
|
|
if (typeof catalog?.catalogVersion !== 'string' || !catalog.catalogVersion.trim()) {
|
|
errors.push('catalog.catalogVersion must be a non-empty string');
|
|
}
|
|
if (!Array.isArray(catalog?.families) || catalog.families.length < 3) {
|
|
errors.push('catalog.families must contain at least three families');
|
|
}
|
|
|
|
for (const family of catalog?.families || []) {
|
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(family.id || '')) {
|
|
errors.push(`invalid family id: ${String(family.id)}`);
|
|
} else if (familyIds.has(family.id)) {
|
|
errors.push(`duplicate family id: ${family.id}`);
|
|
}
|
|
familyIds.add(family.id);
|
|
if (typeof family.label !== 'string' || !family.label.trim()) {
|
|
errors.push(`family ${family.id || '(unknown)'} needs a label`);
|
|
}
|
|
if (!Array.isArray(family.concepts) || family.concepts.length === 0) {
|
|
errors.push(`family ${family.id || '(unknown)'} has no concepts`);
|
|
continue;
|
|
}
|
|
|
|
for (const concept of family.concepts) {
|
|
concepts.push(concept);
|
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept.id || '')) {
|
|
errors.push(`invalid concept id: ${String(concept.id)}`);
|
|
} else if (conceptIds.has(concept.id)) {
|
|
errors.push(`duplicate concept id: ${concept.id}`);
|
|
}
|
|
conceptIds.add(concept.id);
|
|
|
|
const normalized = normalizeConceptForm(concept.form);
|
|
if (!normalized) {
|
|
errors.push(`concept ${concept.id || '(unknown)'} needs a form`);
|
|
} else if (normalizedForms.has(normalized)) {
|
|
errors.push(`duplicate concept form: ${concept.id} and ${normalizedForms.get(normalized)}`);
|
|
}
|
|
normalizedForms.set(normalized, concept.id);
|
|
|
|
if (typeof concept.form !== 'string' || !concept.form.includes(',')) {
|
|
errors.push(`concept ${concept.id || '(unknown)'} must name a form and inherited structure after a comma`);
|
|
}
|
|
if (typeof concept.lineage !== 'string' || !concept.lineage.trim()) {
|
|
errors.push(`concept ${concept.id || '(unknown)'} needs lineage metadata`);
|
|
}
|
|
if (!Array.isArray(concept.tags) || concept.tags.length !== 3 || concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
|
errors.push(`concept ${concept.id || '(unknown)'} must have exactly three structural tags`);
|
|
}
|
|
if (/\b(?:in the style of|styled like|copy of)\b/i.test(concept.form)) {
|
|
errors.push(`concept ${concept.id || '(unknown)'} contains imitation language`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (expectedTotal !== undefined && concepts.length !== expectedTotal) {
|
|
errors.push(`expected ${expectedTotal} concepts, found ${concepts.length}`);
|
|
}
|
|
if (minimumTotal !== undefined && concepts.length < minimumTotal) {
|
|
errors.push(`expected at least ${minimumTotal} concepts, found ${concepts.length}`);
|
|
}
|
|
|
|
if (!Number.isInteger(reviewData?.schemaVersion) || reviewData.schemaVersion < 1) {
|
|
errors.push('reviews.schemaVersion must be a positive integer');
|
|
}
|
|
for (const [id, review] of Object.entries(reviewData?.reviews || {})) {
|
|
if (!conceptIds.has(id)) errors.push(`review references missing concept: ${id}`);
|
|
if (!CONCEPT_STATUSES.has(review?.status)) errors.push(`invalid review status for ${id}: ${String(review?.status)}`);
|
|
if (typeof review?.reviewedBy !== 'string' || !review.reviewedBy.trim()) {
|
|
errors.push(`review ${id} needs reviewedBy`);
|
|
}
|
|
if (typeof review?.reviewedAt !== 'string' || Number.isNaN(Date.parse(review.reviewedAt))) {
|
|
errors.push(`review ${id} needs an ISO reviewedAt timestamp`);
|
|
}
|
|
}
|
|
|
|
const approved = concepts.filter(concept => reviewData?.reviews?.[concept.id]?.status === 'approved');
|
|
const approvedFamilies = new Set(
|
|
(catalog?.families || [])
|
|
.filter(family => family.concepts?.some(concept => reviewData?.reviews?.[concept.id]?.status === 'approved'))
|
|
.map(family => family.id)
|
|
);
|
|
if (approved.length < 3) errors.push('at least three concepts must be approved');
|
|
if (approvedFamilies.size < 3) errors.push('approved concepts must span at least three families');
|
|
|
|
return {
|
|
errors,
|
|
warnings,
|
|
stats: {
|
|
families: familyIds.size,
|
|
concepts: concepts.length,
|
|
approved: approved.length,
|
|
pending: concepts.length - Object.keys(reviewData?.reviews || {}).length,
|
|
rejected: Object.values(reviewData?.reviews || {}).filter(review => review?.status === 'rejected').length,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function approvedPoolRevision(concepts) {
|
|
const payload = concepts
|
|
.filter(concept => concept.status === 'approved')
|
|
.map(concept => `${concept.familyId}:${concept.id}:${concept.form}`)
|
|
.sort()
|
|
.join('\n');
|
|
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12);
|
|
}
|
|
|
|
export function deterministicRank(items, input, idFor = item => item.id) {
|
|
return [...items].sort((a, b) => {
|
|
const scoreA = crypto.createHash('sha256').update(`${input}:${idFor(a)}`).digest('hex');
|
|
const scoreB = crypto.createHash('sha256').update(`${input}:${idFor(b)}`).digest('hex');
|
|
return scoreB.localeCompare(scoreA) || idFor(a).localeCompare(idFor(b));
|
|
});
|
|
}
|