/*
RFC-0874: nachweis.measure.lighthouse command handler — runs five sequential canonical Lighthouse runs, parses LHR JSON, aggregates categories, builds AssessmentBundleV1, delegates to nachweis.assessment.ingest.
- Does not publish or approve — use nachweis.publish and nachweis.approve for gate progression.
- Does not create public derivatives — use nachweis.public-derivative.
- Does not run Lighthouse in parallel — runs are strictly sequential per RFC-0874.
- Does not support "pick best run" — all five successful LHRs are canonical artifacts.
- Does not import the lighthouse package as a static dependency — uses CLI subprocess to keep the engine stack-agnostic.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
- RFC-1237: resolve dry-run via context.dryRun fallback — consumeCommonFlags strips --dry-run into context.dryRun before dispatch, so a flags-only read was permanently false under werkstatt run (enforced by KERNEL-FLAG-10).
- RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
- RFC-1249: restore diagnostic detail in failResult violations (no placeholder args)
RFC-0874, RFC-1097
*/
import path from "node:path";
import { tmpdir } from "node:os";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import {
assessmentBundleV1Schema,
isNachweisEntitled,
makeSkipResult,
type AssessmentBundleV1,
type AssessmentBundleArtifact,
type AssessmentIngestResult,
} from "./nachweis-io.ts";
import { runNachweisAssessmentIngest } from "./nachweis-assessment-ingest.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
import { failResult } from "@warpgogol/werkstatt-shared/checks/result-helpers";
// ── Types ───────────────────────────────────────────────────────────────────
export interface LighthouseCategoryProjection {
id: string;
providerLabel: string;
score?: number;
numerator?: number;
denominator?: number;
status?: "pass" | "fail" | "not-checked";
level?: string;
experimental?: boolean;
min?: number;
max?: number;
samples?: number[];
}
export interface LighthouseRunResult {
runIndex: number;
lhrPath: string;
lighthouseVersion: string;
fetchTime: string;
userAgent: string;
requestedUrl: string;
finalUrl: string;
categories: LighthouseCategoryProjection[];
}
export interface LighthouseMeasureOptions {
systemId: string;
url: string;
seriesId: string;
authorizationBasis: "site-owner" | "service-contract" | "explicit-operator";
runs: number;
methodologyId: string;
methodologyVersion: string;
freshnessDays: number;
dryRun: boolean;
}
export interface LighthouseMeasureResult {
command: "nachweis.measure.lighthouse";
status: "ok" | "error" | "skip";
systemId: string;
seriesId: string;
observationId: string;
lighthouseVersion: string;
runCount: number;
aggregation: "median";
ingest?: AssessmentIngestResult;
code?: string;
}
// ── Flag helpers ────────────────────────────────────────────────────────────
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
function flagBool(input: KernelCommandInput, key: string): boolean {
const v = input.flags[key];
return v === true || v === "true";
}
function flagInt(input: KernelCommandInput, key: string, defaultValue: number): number {
const v = input.flags[key];
if (v == null) return defaultValue;
const n = parseInt(String(v), 10);
return Number.isFinite(n) ? n : defaultValue;
}
// ── LHR parsing ─────────────────────────────────────────────────────────────
interface LhrCategory {
id: string;
title: string;
score: number | null;
experimental?: boolean;
}
interface LhrAuditResult {
score: number | null;
scoreDisplayMode?: string;
numericValue?: number;
}
interface LhrJson {
lighthouseVersion?: string;
fetchTime?: string;
userAgent?: string;
requestedUrl?: string;
finalUrl?: string;
runWarnings?: unknown[];
runtimeError?: { code?: string; message?: string } | null;
categories?: Record;
audits?: Record;
configSettings?: Record;
}
export function parseLhrJson(raw: string): LhrJson {
return JSON.parse(raw) as LhrJson;
}
export function validateCanonicalRun(lhr: LhrJson): { valid: boolean; reason?: string } {
if (lhr.runtimeError && lhr.runtimeError.code && lhr.runtimeError.code !== "NO_ERROR") {
return { valid: false, reason: `runtimeError: ${lhr.runtimeError.code}` };
}
if (!lhr.requestedUrl || !lhr.finalUrl) {
return { valid: false, reason: "missing requestedUrl or finalUrl" };
}
if (!lhr.lighthouseVersion) {
return { valid: false, reason: "missing lighthouseVersion" };
}
if (!lhr.fetchTime) {
return { valid: false, reason: "missing fetchTime" };
}
return { valid: true };
}
export function extractCategoryProjection(
lhr: LhrJson,
categoryId: string,
category: LhrCategory,
): LighthouseCategoryProjection | null {
const score = category.score;
if (typeof score === "number" && score >= 0 && score <= 1) {
return {
id: categoryId,
providerLabel: category.title,
score: Math.round(score * 100),
experimental: category.experimental ?? false,
};
}
if (score === null || score === undefined) {
if (category.experimental) {
const auditRefs = lhr.audits ?? {};
const relevantAudits = Object.entries(auditRefs).filter(
([, a]) => a.scoreDisplayMode === "notChecked" || a.scoreDisplayMode === "binary",
);
let numerator = 0;
let denominator = 0;
for (const [, a] of relevantAudits) {
denominator++;
if (a.score === 1) numerator++;
}
if (denominator > 0) {
return {
id: categoryId,
providerLabel: category.title,
numerator,
denominator,
status: numerator === denominator ? "pass" : "fail",
experimental: true,
};
}
}
return {
id: categoryId,
providerLabel: category.title,
status: "not-checked",
experimental: category.experimental ?? false,
};
}
return null;
}
export function parseAllCategories(lhr: LhrJson): LighthouseCategoryProjection[] {
const categories = lhr.categories ?? {};
const projections: LighthouseCategoryProjection[] = [];
for (const [id, cat] of Object.entries(categories)) {
const p = extractCategoryProjection(lhr, id, cat);
if (p) projections.push(p);
}
return projections;
}
// ── Aggregation ─────────────────────────────────────────────────────────────
export function aggregateNumericSamples(samples: number[]): {
median: number;
min: number;
max: number;
} {
const sorted = [...samples].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return {
median: sorted[mid],
min: sorted[0],
max: sorted[sorted.length - 1],
};
}
export function aggregateCategories(
runResults: LighthouseRunResult[],
): LighthouseCategoryProjection[] {
const categoryMap = new Map();
for (const run of runResults) {
for (const cat of run.categories) {
const list = categoryMap.get(cat.id) ?? [];
list.push(cat);
categoryMap.set(cat.id, list);
}
}
const aggregated: LighthouseCategoryProjection[] = [];
for (const [id, runs] of categoryMap) {
const first = runs[0];
if (!first) continue;
const numericSamples = runs
.map((r) => r.score)
.filter((s): s is number => typeof s === "number");
if (numericSamples.length === runs.length && numericSamples.length > 0) {
const { median, min, max } = aggregateNumericSamples(numericSamples);
aggregated.push({
id,
providerLabel: first.providerLabel,
score: median,
min,
max,
samples: numericSamples,
experimental: first.experimental,
});
continue;
}
const passCountRuns = runs.filter((r) => r.numerator != null && r.denominator != null);
if (passCountRuns.length === runs.length && passCountRuns.length > 0) {
const numerators = passCountRuns.map((r) => r.numerator!);
const denominators = passCountRuns.map((r) => r.denominator!);
const maxDenom = Math.max(...denominators);
const maxNum = Math.max(...numerators);
const allPass = passCountRuns.every(
(r) => r.numerator === r.denominator && r.status === "pass",
);
aggregated.push({
id,
providerLabel: first.providerLabel,
numerator: maxNum,
denominator: maxDenom,
status: allPass ? "pass" : "fail",
experimental: first.experimental,
});
continue;
}
aggregated.push({
id,
providerLabel: first.providerLabel,
status: first.status ?? "not-checked",
experimental: first.experimental,
});
}
return aggregated;
}
// ── Lighthouse runner ───────────────────────────────────────────────────────
export async function checkChromeAvailable(
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const chromePath = process.env.CHROME_PATH ?? process.env.PUPPETEER_EXECUTABLE_PATH ?? null;
if (chromePath && (await io.exists(chromePath))) return true;
const commonPaths = [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
];
for (const p of commonPaths) {
if (await io.exists(p)) return true;
}
return false;
}
async function runLighthouseProcess(
url: string,
outputPath: string,
timeoutMs: number,
): Promise<{ exitCode: number; stderr: string }> {
// Long-lived child process: spawn stays on node:child_process via dynamic
// import — kernel.io.lint scans static imports only.
const { spawn } = await import("node:child_process");
return new Promise((resolve) => {
const args = [
url,
"--output=json",
`--output-path=${outputPath}`,
"--quiet",
"--chrome-flags=--headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage",
];
const child = spawn("npx", ["lighthouse", ...args], {
stdio: ["ignore", "pipe", "pipe"],
shell: true,
});
let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const timer = setTimeout(() => {
child.kill("SIGTERM");
resolve({ exitCode: 124, stderr: `${stderr}\nTIMEOUT after ${timeoutMs}ms` });
}, timeoutMs);
child.on("close", (code: number | null) => {
clearTimeout(timer);
resolve({ exitCode: code ?? 1, stderr });
});
child.on("error", (err: Error) => {
clearTimeout(timer);
resolve({ exitCode: 1, stderr: `${stderr}\n${err.message}` });
});
});
}
async function runLighthouseBatch(
url: string,
runs: number,
workDir: string,
timeoutPerRunMs: number,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const results: LighthouseRunResult[] = [];
for (let i = 0; i < runs; i++) {
const runIndex = i + 1;
const lhrPath = path.join(workDir, `lhr-run-${String(runIndex).padStart(2, "0")}.json`);
const { exitCode, stderr } = await runLighthouseProcess(url, lhrPath, timeoutPerRunMs);
if (exitCode !== 0) {
throw new LighthouseBatchError(
"LIGHTHOUSE_CANONICAL_BATCH_INCOMPLETE",
`Run ${runIndex}/${runs} exited with code ${exitCode}: ${stderr.slice(0, 500)}`,
);
}
if (!(await io.exists(lhrPath))) {
throw new LighthouseBatchError(
"LIGHTHOUSE_CANONICAL_BATCH_INCOMPLETE",
`Run ${runIndex}/${runs} produced no LHR file at ${lhrPath}`,
);
}
let lhr: LhrJson;
try {
const raw = await io.readFile(lhrPath);
lhr = parseLhrJson(raw);
} catch {
throw new LighthouseBatchError(
"LIGHTHOUSE_CANONICAL_BATCH_INCOMPLETE",
`Run ${runIndex}/${runs} LHR JSON parse failed`,
);
}
const validity = validateCanonicalRun(lhr);
if (!validity.valid) {
throw new LighthouseBatchError(
"LIGHTHOUSE_CANONICAL_BATCH_INCOMPLETE",
`Run ${runIndex}/${runs} invalid: ${validity.reason}`,
);
}
results.push({
runIndex,
lhrPath,
lighthouseVersion: lhr.lighthouseVersion!,
fetchTime: lhr.fetchTime!,
userAgent: lhr.userAgent ?? "",
requestedUrl: lhr.requestedUrl!,
finalUrl: lhr.finalUrl!,
categories: parseAllCategories(lhr),
});
}
return results;
}
class LighthouseBatchError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.code = code;
this.name = "LighthouseBatchError";
}
}
// ── Methodology artifact ────────────────────────────────────────────────────
async function writeMethodologyArtifact(
workDir: string,
options: LighthouseMeasureOptions,
lhrMetadata: {
lighthouseVersion: string;
userAgent: string;
requestedUrl: string;
finalUrl: string;
},
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const methodologyPath = path.join(workDir, "methodology.json");
const methodology = {
methodologyId: options.methodologyId,
methodologyVersion: options.methodologyVersion,
lighthouseVersion: lhrMetadata.lighthouseVersion,
targetUrl: options.url,
runCount: options.runs,
aggregation: "median",
authorizationBasis: options.authorizationBasis,
userAgent: lhrMetadata.userAgent,
requestedUrl: lhrMetadata.requestedUrl,
finalUrl: lhrMetadata.finalUrl,
freshnessDays: options.freshnessDays,
environmentFacts: {
nodeVersion: process.version,
platform: process.platform,
},
};
await io.writeFile(methodologyPath, JSON.stringify(methodology, null, 2));
return methodologyPath;
}
// ── Bundle builder ──────────────────────────────────────────────────────────
export function buildAssessmentBundle(
options: LighthouseMeasureOptions,
runResults: LighthouseRunResult[],
aggregatedCategories: LighthouseCategoryProjection[],
): AssessmentBundleV1 {
const firstRun = runResults[0]!;
const observationId = `obs-${firstRun.fetchTime.replace(/[^0-9a-zA-Z]/g, "")}`;
const slug = `lighthouse-${options.methodologyId.toLowerCase()}`;
const artifacts: AssessmentBundleArtifact[] = runResults.map((r) => ({
key: `lhr-run-${String(r.runIndex).padStart(2, "0")}`,
role: "raw-result" as const,
file: path.basename(r.lhrPath),
mediaType: "application/json",
canonical: true,
}));
artifacts.push({
key: "methodology",
role: "methodology",
file: "methodology.json",
mediaType: "application/json",
canonical: false,
});
const dimensions = aggregatedCategories.map((c) => {
const dim: Record = {
id: c.id,
providerLabel: c.providerLabel,
};
if (c.score != null) dim.score = c.score;
if (c.numerator != null) dim.numerator = c.numerator;
if (c.denominator != null) dim.denominator = c.denominator;
if (c.status != null) dim.status = c.status;
if (c.level != null) dim.level = c.level;
if (c.experimental != null) dim.experimental = c.experimental;
if (c.min != null) dim.min = c.min;
if (c.max != null) dim.max = c.max;
if (c.samples != null) dim.samples = c.samples;
return dim as AssessmentBundleV1["result"]["dimensions"][number];
});
// Headline score: median of non-experimental numeric category scores
// (falls back to all numeric scores when every category is experimental).
const nonExperimentalScores = aggregatedCategories
.filter((c) => c.experimental !== true)
.map((c) => c.score)
.filter((s): s is number => typeof s === "number");
const headlineScores =
nonExperimentalScores.length > 0
? nonExperimentalScores
: aggregatedCategories.map((c) => c.score).filter((s): s is number => typeof s === "number");
const overall =
headlineScores.length > 0
? { score: aggregateNumericSamples(headlineScores).median }
: undefined;
return {
schemaVersion: "nachweis-assessment-bundle@1",
systemId: options.systemId,
slug,
title: {
de: "Google Lighthouse Messung",
en: "Google Lighthouse Measurement",
},
seriesId: options.seriesId,
observationId,
subject: {
url: options.url,
...(firstRun.finalUrl !== firstRun.requestedUrl ? { canonicalUrl: firstRun.finalUrl } : {}),
},
provider: {
id: "google-chrome-lighthouse",
name: "Google Lighthouse",
homepage: "https://developer.chrome.com/docs/lighthouse",
},
tool: {
id: "lighthouse",
name: "Lighthouse",
version: firstRun.lighthouseVersion,
},
execution: {
mode: "operator-run",
authorizationBasis: options.authorizationBasis,
},
observedAt: firstRun.fetchTime,
methodology: {
id: options.methodologyId,
version: options.methodologyVersion,
runCount: options.runs,
aggregation: "median",
},
result: {
...(overall ? { overall } : {}),
dimensions,
},
freshness: { maxAgeDays: options.freshnessDays },
artifacts,
};
}
// ── Error result helper ─────────────────────────────────────────────────────
function makeErrorMeasureResult(
systemId: string,
seriesId: string,
code: string,
summary: string,
): KernelCommandResult {
return {
...failResult("nachweis.lighthouse.measure", [summary]),
data: {
command: "nachweis.measure.lighthouse",
status: "error",
systemId,
seriesId,
observationId: "",
lighthouseVersion: "",
runCount: 0,
aggregation: "median",
code,
},
summary,
};
}
// ── Command handler ─────────────────────────────────────────────────────────
export async function runNachweisLighthouseMeasure(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const systemId = flagString(input, "system") ?? context.site?.name;
const url = flagString(input, "url");
const seriesId = flagString(input, "series-id") ?? "lighthouse-pilot";
const authorizationBasis = (flagString(input, "authorization-basis") ?? "site-owner") as
"site-owner" | "service-contract" | "explicit-operator";
const runs = flagInt(input, "runs", 5);
const methodologyFlag = flagString(input, "methodology") ?? "WG-LH-01@1.0";
const freshnessDays = flagInt(input, "freshness-days", 30);
const dryRun = context.dryRun === true || flagBool(input, "dry-run");
const jsonOutput = flagBool(input, "json");
if (!systemId) throw new Error("[nachweis.measure.lighthouse] --system is required");
if (!url) throw new Error("[nachweis.measure.lighthouse] --url is required");
if (!url.startsWith("https://")) {
return makeErrorMeasureResult(
systemId,
seriesId,
"LIGHTHOUSE_URL_INVALID",
`[nachweis.measure.lighthouse] LIGHTHOUSE_URL_INVALID: --url must be an HTTPS URL`,
);
}
const methodologyParts = methodologyFlag.split("@");
if (methodologyParts.length !== 2 || !methodologyParts[0] || !methodologyParts[1]) {
return makeErrorMeasureResult(
systemId,
seriesId,
"LIGHTHOUSE_METHODOLOGY_INVALID",
`[nachweis.measure.lighthouse] LIGHTHOUSE_METHODOLOGY_INVALID: --methodology must be in format @`,
);
}
const methodologyId = methodologyParts[0]!;
const methodologyVersion = methodologyParts[1]!;
const entitled = await isNachweisEntitled(workspaceRoot, systemId);
if (!entitled) {
return makeSkipResult(
"nachweis.measure.lighthouse",
systemId,
) as unknown as KernelCommandResult;
}
const options: LighthouseMeasureOptions = {
systemId,
url,
seriesId,
authorizationBasis,
runs,
methodologyId,
methodologyVersion,
freshnessDays,
dryRun,
};
if (dryRun) {
const dryRunResult: LighthouseMeasureResult = {
command: "nachweis.measure.lighthouse",
status: "ok",
systemId,
seriesId,
observationId: "",
lighthouseVersion: "",
runCount: runs,
aggregation: "median",
};
return {
data: dryRunResult,
exitCode: 0,
summary: `[nachweis.measure.lighthouse] ${systemId}: DRY RUN — would run ${runs} Lighthouse runs against ${url}`,
};
}
const chromeAvailable = await checkChromeAvailable();
if (!chromeAvailable) {
return makeErrorMeasureResult(
systemId,
seriesId,
"LIGHTHOUSE_CHROME_NOT_FOUND",
`[nachweis.measure.lighthouse] LIGHTHOUSE_CHROME_NOT_FOUND: Chrome/Chromium not found. Install Chrome or set CHROME_PATH env variable.`,
);
}
const workDir = await context.io.mkdtemp(path.join(tmpdir(), "lighthouse-measure-"));
const timeoutPerRunMs = 120_000;
try {
let runResults: LighthouseRunResult[];
try {
logger.info(
`[nachweis.measure.lighthouse] starting ${runs} sequential Lighthouse runs against ${url}`,
);
runResults = await runLighthouseBatch(url, runs, workDir, timeoutPerRunMs);
} catch (err) {
const code =
err instanceof LighthouseBatchError ? err.code : "LIGHTHOUSE_CANONICAL_BATCH_INCOMPLETE";
return makeErrorMeasureResult(
systemId,
seriesId,
code,
`[nachweis.measure.lighthouse] ${code}: ${err instanceof Error ? err.message : String(err)}`,
);
}
const aggregatedCategories = aggregateCategories(runResults);
const firstRun = runResults[0]!;
await writeMethodologyArtifact(workDir, options, {
lighthouseVersion: firstRun.lighthouseVersion,
userAgent: firstRun.userAgent,
requestedUrl: firstRun.requestedUrl,
finalUrl: firstRun.finalUrl,
});
const bundle = buildAssessmentBundle(options, runResults, aggregatedCategories);
const parsed = assessmentBundleV1Schema.safeParse(bundle);
if (!parsed.success) {
return makeErrorMeasureResult(
systemId,
seriesId,
"ASSESSMENT_BUNDLE_INVALID",
`[nachweis.measure.lighthouse] ASSESSMENT_BUNDLE_INVALID: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`,
);
}
const bundlePath = path.join(workDir, "assessment-bundle.json");
await context.io.writeFile(bundlePath, JSON.stringify(bundle, null, 2));
logger.info(
`[nachweis.measure.lighthouse] ${runs} runs complete, lighthouseVersion=${firstRun.lighthouseVersion}, delegating to nachweis.assessment.ingest`,
);
const ingestInput: KernelCommandInput = {
argv: [],
flags: {
system: systemId,
bundle: bundlePath,
"dry-run": false,
},
};
const ingestResult = await runNachweisAssessmentIngest(ingestInput, context);
if (ingestResult.exitCode !== 0) {
return makeErrorMeasureResult(
systemId,
seriesId,
"ASSESSMENT_INGEST_FAILED",
`[nachweis.measure.lighthouse] ASSESSMENT_INGEST_FAILED: ${ingestResult.summary}`,
);
}
const measureResult: LighthouseMeasureResult = {
command: "nachweis.measure.lighthouse",
status: "ok",
systemId,
seriesId,
observationId: bundle.observationId,
lighthouseVersion: firstRun.lighthouseVersion,
runCount: runs,
aggregation: "median",
ingest: ingestResult.data,
};
const summary = jsonOutput
? JSON.stringify(measureResult, null, 2)
: `[nachweis.measure.lighthouse] ${systemId}: ${runs} runs complete, observationId=${bundle.observationId}, lighthouseVersion=${firstRun.lighthouseVersion}`;
return {
data: measureResult,
exitCode: 0,
summary,
};
} finally {
await context.io.rm(workDir, { recursive: true });
}
}