import fs from 'node:fs' import crypto from 'node:crypto' const sourcePath='contracts/experiments/kbx.experiments.json' const text=fs.readFileSync(sourcePath,'utf8'), src=JSON.parse(text) const sha=crypto.createHash('sha256').update(text).digest('hex') const telemetry=JSON.parse(fs.readFileSync('contracts/telemetry/kbx.telemetry.json','utf8')) const metricKeys=new Set(telemetry.metrics.map(x=>x.key)) const flagIds=new Set(), expIds=new Set(), allowed=new Set(src.principles.allowedSurfaces) for(const flag of src.featureFlags){ if(flagIds.has(flag.id))throw new Error(`duplicate feature flag: ${flag.id}`);flagIds.add(flag.id) if(!allowed.has(flag.surface))throw new Error(`unsafe/unknown rollout surface: ${flag.id} -> ${flag.surface}`) if(flag.killSwitch!==true)throw new Error(`kill switch is required: ${flag.id}`) } const runningByScreen=new Map() for(const exp of src.experiments){ if(expIds.has(exp.id))throw new Error(`duplicate experiment: ${exp.id}`);expIds.add(exp.id) if(!flagIds.has(exp.flagId))throw new Error(`experiment references unknown flag: ${exp.id}`) if(exp.rolloutPercent<0||exp.rolloutPercent>100)throw new Error(`invalid rollout percent: ${exp.id}`) if(exp.minExposurePerVariant<100)throw new Error(`minimum sample is too small for operational decision: ${exp.id}`) if(exp.variants.filter(x=>x.key==='control').length!==1)throw new Error(`exactly one control variant required: ${exp.id}`) if(exp.variants.reduce((n,x)=>n+x.weight,0)!==100)throw new Error(`variant weights must total 100: ${exp.id}`) for(const metric of [exp.primaryMetric,...(exp.guardrails??[]),...(exp.globalGuardrails??[])])if(!metricKeys.has(metric.key))throw new Error(`unknown UX metric ${metric.key} in ${exp.id}`) if(exp.state==='running')runningByScreen.set(exp.screenId,(runningByScreen.get(exp.screenId)??0)+1) } for(const [screen,count] of runningByScreen)if(count>src.principles.maxRunningExperimentsPerScreen)throw new Error(`too many concurrent experiments on ${screen}`) const manifest={schemaVersion:src.schemaVersion,experimentVersion:src.experimentVersion,sourceSha256:sha,principles:src.principles,featureFlags:src.featureFlags,experiments:src.experiments} fs.mkdirSync('generated',{recursive:true});fs.writeFileSync('generated/experiment-manifest.json',JSON.stringify(manifest,null,2)+'\n') const ts=`// generated from ${sourcePath}; do not edit.\nexport const kbxFeatureFlags = ${JSON.stringify(Object.fromEntries(src.featureFlags.map(x=>[x.id,x])),null,2)} as const\nexport const kbxExperiments = ${JSON.stringify(Object.fromEntries(src.experiments.map(x=>[x.id,x])),null,2)} as const\nexport type KbxFeatureFlagId = keyof typeof kbxFeatureFlags\nexport type KbxExperimentId = keyof typeof kbxExperiments\nexport const kbxExperimentSourceSha256 = '${sha}' as const\n` fs.mkdirSync('packages/kbx-contracts/src/generated',{recursive:true});fs.writeFileSync('packages/kbx-contracts/src/generated/experimentCatalog.ts',ts) const esc=s=>s.replaceAll('"','\\"') const csFlags=src.featureFlags.map(x=>` ["${x.id}"] = new("${x.id}", "${x.screenId}", "${x.surface}", "${x.defaultVariant}", ${x.killSwitch?'true':'false'})`).join(',\n') const csExps=src.experiments.map(x=>` ["${x.id}"] = new("${x.id}", "${x.flagId}", "${x.screenId}", "${x.state}", ${x.rolloutPercent}, ${x.minExposurePerVariant}, new[] { ${x.variants.map(v=>`new KbxVariantWeight("${v.key}", ${v.weight})`).join(', ')} }, "${x.primaryMetric.key}", "${x.primaryMetric.direction}", ${x.primaryMetric.minimumImprovementPercent})`).join(',\n') const cs=`// generated from ${sourcePath}; do not edit.\nnamespace Kbx.Shared.Experiments.Generated;\n\npublic sealed record KbxFeatureFlagDefinition(string Id,string ScreenId,string Surface,string DefaultVariant,bool KillSwitch);\npublic sealed record KbxVariantWeight(string Key,int Weight);\npublic sealed record KbxExperimentDefinition(string Id,string FlagId,string ScreenId,string State,int RolloutPercent,int MinExposurePerVariant,IReadOnlyList Variants,string PrimaryMetric,string PrimaryDirection,double MinimumImprovementPercent);\npublic static class KbxExperimentCatalog\n{\n public const string SourceSha256 = "${sha}";\n public static readonly IReadOnlyDictionary FeatureFlags = new Dictionary(StringComparer.Ordinal)\n {\n${csFlags}\n };\n public static readonly IReadOnlyDictionary Experiments = new Dictionary(StringComparer.Ordinal)\n {\n${csExps}\n };\n}\n` fs.mkdirSync('backend/Shared/Experiments/Generated',{recursive:true});fs.writeFileSync('backend/Shared/Experiments/Generated/KbxExperimentCatalog.g.cs',cs) console.log(`generated experiment contracts: flags=${src.featureFlags.length}, experiments=${src.experiments.length}`)