Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
151 lines
5.8 KiB
JavaScript
151 lines
5.8 KiB
JavaScript
// harness.config.json loader: the single project-facts manifest consumed by
|
|
// hooks/scripts at runtime (and by the renderer at sync time in a later phase).
|
|
// Stdlib only. Fail-closed on a malformed config (throws a clear Error); a
|
|
// MISSING file degrades to the built-in defaults so a config-less repo still
|
|
// runs a minimal harness.
|
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { REPO } from "./paths.mjs";
|
|
|
|
export const CONFIG_FILENAME = "harness.config.json";
|
|
|
|
// Minimal safe baseline. The committed harness.config.json overrides these.
|
|
// Optional capabilities (deploy, app) are ABSENT here on purpose: a capability
|
|
// exists iff its block is present in the config.
|
|
const DEFAULTS = {
|
|
name: "harness",
|
|
layout: { src: "src", e2e: "e2e", adr: "adr" },
|
|
validation: { steps: [], extraForbidden: [] },
|
|
worktree: { provision: "npm-link" },
|
|
skills: { developerMenu: [] },
|
|
roles: {},
|
|
launcher: {
|
|
sessionDirEnv: "CHAT_SESSION_DIR",
|
|
turnSentinelDir: null,
|
|
postCheckoutScript: null,
|
|
logsDir: null,
|
|
},
|
|
};
|
|
|
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
|
|
// Deep-merge `override` onto `base`. Objects merge recursively; arrays and
|
|
// scalars from `override` REPLACE (never concatenate): a config that lists
|
|
// validation.steps fully replaces the default empty list.
|
|
function deepMerge(base, override) {
|
|
if (!isObject(base) || !isObject(override)) return override;
|
|
const out = { ...base };
|
|
for (const [k, v] of Object.entries(override)) {
|
|
out[k] = isObject(v) && isObject(base[k]) ? deepMerge(base[k], v) : v;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
class ConfigError extends Error {}
|
|
|
|
// Throw a clear, prefixed error so a fail-closed consumer surfaces exactly what
|
|
// is wrong with the config.
|
|
function fail(msg) {
|
|
throw new ConfigError(`${CONFIG_FILENAME}: ${msg}`);
|
|
}
|
|
|
|
function validate(cfg) {
|
|
if (!isObject(cfg)) fail("top level must be a JSON object");
|
|
|
|
if (!isObject(cfg.validation) || !Array.isArray(cfg.validation.steps)) {
|
|
fail("`validation.steps` must be an array");
|
|
}
|
|
for (const [i, step] of cfg.validation.steps.entries()) {
|
|
if (!isObject(step)) fail(`validation.steps[${i}] must be an object`);
|
|
if (typeof step.id !== "string" || !step.id) {
|
|
fail(`validation.steps[${i}] needs a non-empty string \`id\``);
|
|
}
|
|
if (typeof step.kind !== "string" || !step.kind) {
|
|
fail(`validation.steps[${step.id}] needs a non-empty string \`kind\``);
|
|
}
|
|
const isVitest = step.runner === "vitest";
|
|
if (!isVitest && (typeof step.command !== "string" || !step.command)) {
|
|
fail(`validation.steps[${step.id}] needs a \`command\` (or runner:"vitest")`);
|
|
}
|
|
if (isVitest && (typeof step.config !== "string" || !Array.isArray(step.projects))) {
|
|
fail(`validation.steps[${step.id}] (vitest) needs \`config\` and \`projects[]\``);
|
|
}
|
|
}
|
|
|
|
if (!isObject(cfg.roles)) fail("`roles` must be an object");
|
|
for (const [name, role] of Object.entries(cfg.roles)) {
|
|
if (!isObject(role) || typeof role.model !== "string" || !role.model) {
|
|
fail(`roles.${name} needs a non-empty string \`model\``);
|
|
}
|
|
}
|
|
|
|
if ("deploy" in cfg && cfg.deploy !== undefined) {
|
|
if (!isObject(cfg.deploy) || !Array.isArray(cfg.deploy.relevantGlobs)) {
|
|
fail("`deploy.relevantGlobs` must be an array when `deploy` is present");
|
|
}
|
|
}
|
|
return cfg;
|
|
}
|
|
|
|
const cache = new Map();
|
|
|
|
/**
|
|
* Load and validate the harness config, merged over the defaults.
|
|
* @param {string} [repo] repo root holding harness.config.json (defaults to REPO)
|
|
* @returns {object} the validated, merged config
|
|
* @throws {Error} on malformed JSON or an invalid shape (fail-closed)
|
|
*/
|
|
export function loadConfig(repo = REPO) {
|
|
if (cache.has(repo)) return cache.get(repo);
|
|
const path = join(repo, CONFIG_FILENAME);
|
|
let merged;
|
|
if (!existsSync(path)) {
|
|
merged = validate(structuredClone(DEFAULTS));
|
|
} else {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
} catch (e) {
|
|
fail(`invalid JSON (${e.message})`);
|
|
}
|
|
merged = validate(deepMerge(DEFAULTS, parsed));
|
|
}
|
|
cache.set(repo, merged);
|
|
return merged;
|
|
}
|
|
|
|
// Test-only: drop the memoized config so a test can reload after writing a new
|
|
// file to the same repo path.
|
|
export function clearConfigCache() {
|
|
cache.clear();
|
|
}
|
|
|
|
// ---- Typed readers used by the config consumers ----------------
|
|
|
|
export const validationSteps = (cfg) => cfg.validation?.steps ?? [];
|
|
export const extraForbidden = (cfg) => cfg.validation?.extraForbidden ?? [];
|
|
export const isDeployEnabled = (cfg) => isObject(cfg.deploy);
|
|
export const deployGlobs = (cfg) => cfg.deploy?.relevantGlobs ?? [];
|
|
export const isAppSmokeEnabled = (cfg) => isObject(cfg.app);
|
|
export const worktreeProvision = (cfg) => cfg.worktree?.provision ?? "npm-link";
|
|
export const roleNames = (cfg) => Object.keys(cfg.roles ?? {});
|
|
export const roleModel = (cfg, role) => cfg.roles?.[role]?.model ?? "";
|
|
export const pipelineRoles = (cfg) =>
|
|
roleNames(cfg).filter((r) => cfg.roles[r]?.pipeline);
|
|
export const debounceRoles = (cfg) =>
|
|
roleNames(cfg).filter((r) => cfg.roles[r]?.debounce);
|
|
// Managed-launcher extension points (empty object when no launcher overlay).
|
|
export const launcher = (cfg) => cfg.launcher ?? {};
|
|
// The format-kind validation step (null when none), used by format-on-write.
|
|
export const formatStep = (cfg) =>
|
|
validationSteps(cfg).find((s) => s.kind === "format") ?? null;
|
|
// Steps a pre-PR / pre-push gate runs on the human path (fast checks only).
|
|
// `changedScoped` steps are EXCLUDED: they need a per-worktree diff base (the
|
|
// session branch) that the human push path lacks, and running their bare command
|
|
// whole-repo would lint/test far more than the change at hand.
|
|
export const prePrSteps = (cfg) =>
|
|
validationSteps(cfg).filter(
|
|
(s) => (s.kind === "typecheck" || s.kind === "lint") && !s.changedScoped,
|
|
);
|