#!/usr/bin/env node /** * validate-snapshot.mjs — pre-flight a group snapshot before it reaches the Studio. * * Mirrors the accept/reject rules in `ingest` * (`apps/hoen-explorer/src/snapshots/snapshot-engine.ts`) and the coverage * counting in `apps/hoen-explorer/src/snapshots/snapshot-engine.ts`. A file that passes * here loads cleanly and reports the coverage shown below. `ingest` is the * source of truth — where the two disagree, `ingest` is right and this needs * updating. * * It also flags what `ingest` accepts silently but an intake session should * not: readings outside their scale, and telemetry that looks like a unit * mix-up. Those are the failure mode that matters — a mistyped reading scores * exactly like a real one. * * Usage: * validate-snapshot.mjs snapshots/*.json * validate-snapshot.mjs --format text snapshots/grp-a.json * validate-snapshot.mjs --model path/to/model.json snapshots/*.json * * Exit: 0 all files load, 1 at least one would be rejected, 2 usage error. * * Style: declarative/functional (see hoen-library CONVENTIONS.md → Script Conventions). */ import { existsSync, readFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const MODEL_REL = join("packages", "hoen-model", "dist", "model.json"); /** Prefer explicit env / cwd / transplanted layout / sibling assessment checkout. */ export const candidateModelPaths = () => [ process.env.HOEN_MODEL_PATH, process.env.HOEN_ASSESSMENT_ROOT && join(process.env.HOEN_ASSESSMENT_ROOT, MODEL_REL), join(process.cwd(), MODEL_REL), // Transplanted under consumer `.agents/skills//scripts` join(here, "..", "..", "..", "..", MODEL_REL), // Sibling: .../hoen-library/library/skills//scripts → .../hoen-assessment join(here, "..", "..", "..", "..", "..", "hoen-assessment", MODEL_REL), ].filter(Boolean); export const resolveDefaultModelPath = () => candidateModelPaths().find((path) => existsSync(path)) ?? join(process.cwd(), MODEL_REL); export const DEFAULT_MODEL_PATH = resolveDefaultModelPath(); const usage = () => `Usage: validate-snapshot.mjs [options] FILE... Check group snapshot JSON against the rules the HoEN Report Studio applies on load. Options: --format FORMAT json (default, machine-readable) | text (console readout) --model PATH Model bundle to read the catalogues from (default: HOEN_MODEL_PATH, HOEN_ASSESSMENT_ROOT, cwd, transplanted .agents layout, or sibling hoen-assessment checkout) -h, --help Show help Exit codes: 0 every file would load 1 at least one file would be rejected 2 usage error `; /** The eight telemetry fields the model reads. Anything else is ignored on ingest. */ export const TELEMETRY_FIELDS = [ "leadTimeHours", "deployPerDay", "totalActiveContributors", "teamsCount", "changeFailPct", "recoveryTimeHours", "incidentsLowPerMonth", "incidentsMedHighPerMonth", ]; /** Ranges the scale actually defines. Outside these, a reading is a typo. */ export const NEED_RANGES = { survey: [0, 10], maturity: [0, 3], spendRank: [1, 5], workshopDotVote: [0, 1], }; /** Sanity bounds for telemetry — wide on purpose; these catch unit mix-ups. */ export const TELEMETRY_SANITY = { leadTimeHours: [0, 2000, "hours (not days or minutes)"], deployPerDay: [0, 500, "deployments per day (not per month)"], totalActiveContributors: [1, 10000, "people"], teamsCount: [1, 1000, "teams"], changeFailPct: [0, 100, "percent 0–100 (not a 0–1 fraction)"], recoveryTimeHours: [0, 2000, "hours (not minutes)"], incidentsLowPerMonth: [0, 10000, "incidents per month (not per year)"], incidentsMedHighPerMonth: [0, 10000, "incidents per month (not per year)"], }; const isNumber = (value) => typeof value === "number" && Number.isFinite(value); const isPlainObject = (value) => !!value && typeof value === "object" && !Array.isArray(value); const isFilledString = (value) => typeof value === "string" && value.trim() !== ""; export const normaliseText = (text) => String(text) .trim() .toLowerCase() .replace(/\s+/g, " ") .replace(/^["']|["']$/g, ""); const truncate = (list, limit = 5) => `${list.slice(0, limit).join(", ")}${list.length > limit ? "…" : ""}`; const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`; /* ---------------------------------------------------------------- catalogues */ export const buildCatalogues = (model) => ({ needIds: new Set(model.needs.map((need) => need.id)), needCount: model.needs.length, questionCount: model.surveyQuestions.length, byId: new Map( model.surveyQuestions.map((question) => [ question.id.trim().toLowerCase(), question, ]), ), byText: new Map( model.surveyQuestions.map((question) => [ normaliseText(question.text), question, ]), ), }); export const loadCatalogues = (modelPath) => buildCatalogues(JSON.parse(readFileSync(modelPath, "utf8"))); /* -------------------------------------------------------------------- survey */ const questionLabel = (stat) => isFilledString(stat.id) ? stat.id.trim() : isFilledString(stat.text) ? `"${String(stat.text).slice(0, 48)}"` : "(unnamed)"; const resolveQuestion = (stat, cat) => (isFilledString(stat.id) && cat.byId.get(stat.id.trim().toLowerCase())) || (isFilledString(stat.text) && cat.byText.get(normaliseText(stat.text))) || null; const questionScore = (stat) => isNumber(stat.average) ? stat.average : isNumber(stat.mean) ? stat.mean : null; export const surveyReport = (questions, cat) => { const rows = questions.filter(isPlainObject).map((stat) => ({ label: questionLabel(stat), def: resolveQuestion(stat, cat), score: questionScore(stat), })); const matchedRows = rows.filter((row) => row.def); return { supplied: rows.length, matched: matchedRows.length, scored: matchedRows.filter((row) => row.score !== null).length, unmatched: rows.filter((row) => !row.def).map((row) => row.label), unscored: matchedRows.filter((row) => row.score === null).map((row) => row.label), outOfScale: matchedRows .filter((row) => row.score !== null && (row.score < 0 || row.score > 10)) .map((row) => `${row.label}: score ${row.score} is outside the 0–10 survey scale`), }; }; /* --------------------------------------------------------------------- needs */ const needEntries = (group) => Object.entries(isPlainObject(group.needs) ? group.needs : {}); const knownNeedEntries = (group, cat) => needEntries(group).filter( ([id, entry]) => cat.needIds.has(id) && isPlainObject(entry), ); const suppliedCount = (entries, field) => entries.filter(([, entry]) => isNumber(entry[field])).length; /** * An explicit `null` is how the template says "nobody supplied this", and * behaves exactly like omitting the key. Anything else non-numeric — "N/A", * "", "3" — is a mistake worth naming. */ const readingWarnings = (entries) => entries.flatMap(([id, entry]) => Object.entries(NEED_RANGES).flatMap(([field, [min, max]]) => !(field in entry) || entry[field] === null ? [] : !isNumber(entry[field]) ? [ `${id}.${field} is ${JSON.stringify(entry[field])}, not a number — it silently falls back to the default. Use null or omit the key.`, ] : entry[field] < min || entry[field] > max ? [`${id}.${field} = ${entry[field]} is outside the ${min}–${max} scale`] : [], ), ); const spendAmountWarnings = (entries, needCount) => { const withAmount = entries.filter(([, entry]) => isNumber(entry.spendAmount)).length; const badAmount = entries .filter(([, entry]) => "spendAmount" in entry && !isNumber(entry.spendAmount)) .map(([id]) => `${id}.spendAmount is not a number`); return [ ...badAmount, ...(withAmount > 0 && withAmount < needCount ? [ `spendAmount on only ${withAmount}/${needCount} needs — the canonical stack rank needs it on every need, so the banded spendRank is used instead`, ] : []), ]; }; /* ----------------------------------------------------------------- telemetry */ const telemetryPresent = (metrics) => TELEMETRY_FIELDS.filter((field) => isNumber(metrics[field])); const telemetryWarnings = (metrics) => [ ...Object.keys(metrics) .filter((field) => !TELEMETRY_FIELDS.includes(field)) .map( (field) => `rawMetrics.${field} is not a field the model reads and will be ignored`, ), ...Object.entries(TELEMETRY_SANITY) .filter( ([field, [min, max]]) => isNumber(metrics[field]) && (metrics[field] < min || metrics[field] > max), ) .map( ([field, [, , unit]]) => `rawMetrics.${field} = ${metrics[field]} looks wrong — expected ${unit}`, ), ]; /* ---------------------------------------------------------------- one group */ const groupCode = (group, label) => isPlainObject(group) && isFilledString(group.code) ? group.code.trim() : label; const rejections = (group, survey) => [ ...(isFilledString(group.asAt) && Number.isFinite(Date.parse(group.asAt)) ? [] : [ 'missing or invalid "asAt" — needs an ISO-8601 datetime, e.g. 2026-07-15T09:00:00.000Z', ]), ...(isPlainObject(group.rawMetrics) ? [] : [ 'missing "rawMetrics" block — the Studio rejects the file outright. Use {} if no telemetry was supplied.', ]), ...(survey.supplied > 0 && survey.matched === 0 ? [ "no survey question matched the catalogue — check the id column, or fall back to matching on question text", ] : []), ]; const hygieneWarnings = (group) => [ ...(isPlainObject(group.participants) && isNumber(group.participants.survey) ? [] : [ "participants.survey is missing — sample-weighted aggregation falls back to team size, then to 1", ]), ...(isFilledString(group.id) ? [] : [ 'no "id" — one is generated at load time, so re-loading the same file creates a second copy in the library', ]), ...(isFilledString(group.code) ? [] : ['no "code" — the group is named after the filename']), ]; const surveyWarnings = (survey) => [ ...(survey.unmatched.length ? [ `${plural(survey.unmatched.length, "question")} matched no catalogue entry and will be dropped: ${truncate(survey.unmatched)}`, ] : []), ...(survey.unscored.length ? [ `${plural(survey.unscored.length, "matched question")} carry no "average" or "mean" and contribute nothing: ${truncate(survey.unscored)}`, ] : []), ...survey.outOfScale, ]; const notesFor = (group, cat, survey, entries) => { const metrics = isPlainObject(group.rawMetrics) ? group.rawMetrics : {}; const present = telemetryPresent(metrics); const absent = TELEMETRY_FIELDS.filter((field) => !present.includes(field)); const amounts = entries.filter(([, entry]) => isNumber(entry.spendAmount)).length; return [ survey.supplied ? `survey: ${survey.scored}/${cat.questionCount} catalogue questions scored` : "survey: no questions block — every need falls back to the mid-scale default of 5/10", `needs supplied — maturity ${suppliedCount(entries, "maturity")}/${cat.needCount}` + `, spend ${suppliedCount(entries, "spendRank")}/${cat.needCount}` + (amounts ? ` (plus ${amounts} spendAmount)` : "") + `, dot-vote ${suppliedCount(entries, "workshopDotVote")}/${cat.needCount}` + (suppliedCount(entries, "survey") ? `, inline survey ${suppliedCount(entries, "survey")}/${cat.needCount}` : ""), `telemetry: ${present.length}/${TELEMETRY_FIELDS.length} fields present`, ...(absent.length ? [` absent: ${absent.join(", ")}`] : []), ]; }; /** One group's verdict. Pure: same input, same report. */ export const checkGroup = (raw, label, cat) => { const notObject = !isPlainObject(raw); const group = notObject ? {} : raw; const survey = surveyReport(Array.isArray(group.questions) ? group.questions : [], cat); const entries = knownNeedEntries(group, cat); const unknownNeeds = needEntries(group) .map(([id]) => id) .filter((id) => !cat.needIds.has(id)); return notObject ? { label, code: label, errors: [`${label}: not an object`], warnings: [], notes: [] } : { label, code: groupCode(group, label), errors: rejections(group, survey), warnings: [ ...(Array.isArray(group.questions) || group.questions === undefined ? [] : ['"questions" is not an array — it will be ignored entirely']), ...surveyWarnings(survey), ...(unknownNeeds.length ? [ `${plural(unknownNeeds.length, "unrecognised need id")} will be ignored: ${truncate(unknownNeeds)}`, ] : []), ...readingWarnings(entries), ...spendAmountWarnings(entries, cat.needCount), ...telemetryWarnings(isPlainObject(group.rawMetrics) ? group.rawMetrics : {}), ...hygieneWarnings(group), ], notes: notesFor(group, cat, survey, entries), }; }; /** Two groups sharing a code are hard to tell apart in the Studio. */ export const withDuplicateCodeWarnings = (reports) => reports.map((report, index) => reports.slice(0, index).some((earlier) => earlier.code === report.code) ? { ...report, warnings: [ ...report.warnings, `code "${report.code}" is also used by ${reports.find((earlier) => earlier.code === report.code).label} — two groups with the same name are hard to tell apart in the Studio`, ], } : report, ); /* -------------------------------------------------------------------- files */ const readEntries = (file) => { try { const parsed = JSON.parse(readFileSync(file, "utf8")); return { ok: true, entries: Array.isArray(parsed) ? parsed : [parsed] }; } catch (error) { return { ok: false, message: `not valid JSON: ${error.message}` }; } }; export const checkFile = (file, cat, read = readEntries) => { const result = read(file); return result.ok ? result.entries.map((entry, index) => checkGroup( entry, result.entries.length > 1 ? `${basename(file)} [${index + 1}]` : basename(file), cat, ), ) : [ { label: basename(file), code: basename(file), errors: [result.message], warnings: [], notes: [], }, ]; }; export const checkFiles = (files, cat, read = readEntries) => withDuplicateCodeWarnings(files.flatMap((file) => checkFile(file, cat, read))); /* ------------------------------------------------------------------ output */ const mark = (report) => report.errors.length ? "✗" : report.warnings.length ? "!" : "✓"; export const renderText = (reports) => [ ...reports.map((report) => [ `\n${mark(report)} ${report.label} (${report.code})`, ...report.notes.map((note) => ` · ${note}`), ...report.warnings.map((warning) => ` ! ${warning}`), ...report.errors.map((error) => ` ✗ ${error}`), ].join("\n"), ), `\n${plural(reports.length, "entry")} checked, ${reports.filter((report) => report.errors.length).length} would be rejected.`, ].join("\n"); export const renderJson = (reports) => JSON.stringify( { checked: reports.length, rejected: reports.filter((report) => report.errors.length).length, reports, }, null, 2, ); /* --------------------------------------------------------------------- CLI */ export const parseArgs = ( args, acc = { files: [], format: "json", model: DEFAULT_MODEL_PATH }, ) => args.length === 0 ? acc : args[0] === "--format" ? parseArgs(args.slice(2), { ...acc, format: args[1] ?? "" }) : args[0] === "--model" ? parseArgs(args.slice(2), { ...acc, model: args[1] ?? "" }) : ["-h", "--help"].includes(args[0]) ? { ...acc, help: true } : args[0].startsWith("--") ? { ...acc, error: `unknown option '${args[0]}'` } : parseArgs(args.slice(1), { ...acc, files: [...acc.files, args[0]] }); const fail = (message) => ( console.error(`Error: ${message}\n${usage()}`), process.exit(2) ); const main = () => { const args = parseArgs(process.argv.slice(2)); args.help && (console.log(usage()), process.exit(0)); args.error && fail(args.error); ["json", "text"].includes(args.format) || fail(`unknown --format '${args.format}' (use json|text)`); args.files.length || fail("at least one FILE is required"); const reports = checkFiles(args.files, loadCatalogues(args.model)); console.log(args.format === "text" ? renderText(reports) : renderJson(reports)); process.exit(reports.some((report) => report.errors.length) ? 1 : 0); }; const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); isMain && main();