@quonfig/cli 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ import { execFile as execFileCb } from 'node:child_process';
2
+ import * as util from 'node:util';
3
+ const execFile = util.promisify(execFileCb);
4
+ /**
5
+ * Redact a token from a URL string so it is safe to display to users.
6
+ */
7
+ export const redactToken = (url) => url.replace(/:([^@/]+)@/, ':***@');
8
+ /**
9
+ * Wrap execFile errors to strip tokens from the message.
10
+ */
11
+ const safeExec = async (file, args, options) => {
12
+ try {
13
+ return await execFile(file, args, { cwd: options?.cwd });
14
+ }
15
+ catch (err) {
16
+ const e = err;
17
+ const message = redactToken(e.message || String(err));
18
+ const stderr = redactToken(e.stderr ?? '');
19
+ const safeErr = new Error(message);
20
+ safeErr.stderr = stderr;
21
+ throw safeErr;
22
+ }
23
+ };
24
+ export const gitClone = async (repoUrl, dir) => {
25
+ await safeExec('git', ['clone', repoUrl, dir]);
26
+ };
27
+ export const isGitRepo = async (dir) => {
28
+ try {
29
+ await execFile('git', ['-C', dir, 'rev-parse', '--git-dir']);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ };
36
+ export const getRemoteUrl = async (dir) => {
37
+ try {
38
+ const { stdout } = await execFile('git', ['-C', dir, 'remote', 'get-url', 'origin']);
39
+ return stdout.trim() || null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ };
45
+ export const isWorkingTreeClean = async (dir) => {
46
+ const { stdout } = await execFile('git', ['-C', dir, 'status', '--porcelain']);
47
+ return stdout.trim() === '';
48
+ };
49
+ export const gitFetch = async (dir) => {
50
+ await safeExec('git', ['-C', dir, 'fetch', 'origin']);
51
+ };
52
+ /**
53
+ * Returns true if origin/main has commits that can be fast-forwarded into the local branch.
54
+ */
55
+ export const canFastForward = async (dir) => {
56
+ try {
57
+ // Get the local HEAD and origin/main SHAs
58
+ const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
59
+ const { stdout: remoteSha } = await execFile('git', ['-C', dir, 'rev-parse', 'origin/main']);
60
+ const local = localSha.trim();
61
+ const remote = remoteSha.trim();
62
+ if (local === remote)
63
+ return false; // already up to date
64
+ // Check if local is an ancestor of remote (i.e. ff is possible)
65
+ try {
66
+ await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', local, remote]);
67
+ return true; // exit code 0 means local is an ancestor of remote
68
+ }
69
+ catch {
70
+ return false; // local has diverged
71
+ }
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ };
77
+ /**
78
+ * Returns true if local has commits not reachable from origin/main (diverged).
79
+ */
80
+ export const hasDivergedFromRemote = async (dir) => {
81
+ try {
82
+ const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
83
+ const { stdout: remoteSha } = await execFile('git', ['-C', dir, 'rev-parse', 'origin/main']);
84
+ const local = localSha.trim();
85
+ const remote = remoteSha.trim();
86
+ if (local === remote)
87
+ return false;
88
+ // If remote is an ancestor of local, local is ahead (diverged for our purposes)
89
+ try {
90
+ await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', remote, local]);
91
+ return true;
92
+ }
93
+ catch {
94
+ // Check if truly diverged (neither is ancestor of the other)
95
+ try {
96
+ await execFile('git', ['-C', dir, 'merge-base', '--is-ancestor', local, remote]);
97
+ return false; // ff possible, not diverged
98
+ }
99
+ catch {
100
+ return true; // truly diverged
101
+ }
102
+ }
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ };
108
+ /**
109
+ * Performs a fast-forward-only merge of origin/main. Returns list of new commit subjects.
110
+ */
111
+ export const gitMergeFfOnly = async (dir) => {
112
+ // Get commits that will be merged (before merge)
113
+ let newCommits = [];
114
+ try {
115
+ const { stdout: localSha } = await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
116
+ const { stdout: log } = await execFile('git', [
117
+ '-C',
118
+ dir,
119
+ 'log',
120
+ '--pretty=format:%s',
121
+ `${localSha.trim()}..origin/main`,
122
+ ]);
123
+ newCommits = log
124
+ .split('\n')
125
+ .map((s) => s.trim())
126
+ .filter(Boolean);
127
+ }
128
+ catch {
129
+ // Non-fatal — we'll still attempt the merge
130
+ }
131
+ await safeExec('git', ['-C', dir, 'merge', '--ff-only', 'origin/main']);
132
+ return newCommits;
133
+ };
134
+ /**
135
+ * Adds or updates the origin remote for a repo.
136
+ */
137
+ export const gitSetRemote = async (dir, url) => {
138
+ const existing = await getRemoteUrl(dir);
139
+ if (existing !== null) {
140
+ await safeExec('git', ['-C', dir, 'remote', 'set-url', 'origin', url]);
141
+ }
142
+ else {
143
+ await safeExec('git', ['-C', dir, 'remote', 'add', 'origin', url]);
144
+ }
145
+ };
146
+ export const gitPushForceLease = async (dir) => {
147
+ await safeExec('git', ['-C', dir, 'push', 'origin', 'main', '--force-with-lease']);
148
+ };
149
+ export const gitPushForce = async (dir) => {
150
+ await safeExec('git', ['-C', dir, 'push', 'origin', 'main', '--force']);
151
+ };
152
+ export const hasAtLeastOneCommit = async (dir) => {
153
+ try {
154
+ await execFile('git', ['-C', dir, 'rev-parse', 'HEAD']);
155
+ return true;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ };
161
+ /**
162
+ * Get the URL stripped of credentials for display purposes.
163
+ */
164
+ export const displayUrl = (url) => redactToken(url);
@@ -0,0 +1,11 @@
1
+ import { GiteaTokenEntry } from './gitea-token-storage.js';
2
+ export interface GiteaTokenResponse {
3
+ token: string;
4
+ repoUrl: string;
5
+ expiresAt: string | null;
6
+ }
7
+ export declare const mintGiteaToken: (workspaceId: string, scope: "read" | "write", purpose: "pull" | "bootstrap") => Promise<GiteaTokenResponse>;
8
+ /**
9
+ * Mint a Gitea read token, store it, and return the entry.
10
+ */
11
+ export declare const mintAndStoreGiteaReadToken: (workspaceId: string) => Promise<GiteaTokenEntry>;
@@ -0,0 +1,45 @@
1
+ import { getApiUrl } from './domain-urls.js';
2
+ import { getValidAccessToken } from './get-valid-token.js';
3
+ import { saveGiteaToken } from './gitea-token-storage.js';
4
+ export const mintGiteaToken = async (workspaceId, scope, purpose) => {
5
+ const accessToken = await getValidAccessToken();
6
+ const apiUrl = getApiUrl();
7
+ const res = await fetch(`${apiUrl}/api/v1/gitea/token`, {
8
+ method: 'POST',
9
+ headers: {
10
+ 'Authorization': `Bearer ${accessToken}`,
11
+ 'Content-Type': 'application/json',
12
+ },
13
+ body: JSON.stringify({ json: { scope, workspaceId, purpose } }),
14
+ });
15
+ if (!res.ok) {
16
+ const text = await res.text().catch(() => '');
17
+ let message = `HTTP ${res.status}`;
18
+ try {
19
+ const body = JSON.parse(text);
20
+ // oRPC error shape
21
+ if (body?.json?.message)
22
+ message = body.json.message;
23
+ else if (body?.message)
24
+ message = body.message;
25
+ }
26
+ catch { /* use raw text */ }
27
+ throw new Error(message || text);
28
+ }
29
+ const body = await res.json();
30
+ const data = body.json ?? body;
31
+ return data;
32
+ };
33
+ /**
34
+ * Mint a Gitea read token, store it, and return the entry.
35
+ */
36
+ export const mintAndStoreGiteaReadToken = async (workspaceId) => {
37
+ const data = await mintGiteaToken(workspaceId, 'read', 'pull');
38
+ const entry = {
39
+ token: data.token,
40
+ repoUrl: data.repoUrl,
41
+ expiresAt: data.expiresAt,
42
+ };
43
+ await saveGiteaToken(workspaceId, entry);
44
+ return entry;
45
+ };
@@ -0,0 +1,8 @@
1
+ export interface GiteaTokenEntry {
2
+ token: string;
3
+ repoUrl: string;
4
+ expiresAt: string | null;
5
+ }
6
+ export declare const loadGiteaToken: (workspaceId: string) => Promise<GiteaTokenEntry | null>;
7
+ export declare const saveGiteaToken: (workspaceId: string, entry: GiteaTokenEntry) => Promise<void>;
8
+ export declare const isGiteaTokenExpired: (entry: GiteaTokenEntry) => boolean;
@@ -0,0 +1,47 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { getDomain } from './domain-urls.js';
5
+ const BUFFER_MS = 5 * 60 * 1000; // 5 minutes
6
+ const getQuonfigDir = () => path.join(os.homedir(), '.quonfig');
7
+ const getGiteaTokenFile = () => {
8
+ const domain = getDomain();
9
+ const filename = domain !== 'quonfig.com' ? `gitea-tokens-${domain.replaceAll('.', '-')}.json` : 'gitea-tokens.json';
10
+ return path.join(getQuonfigDir(), filename);
11
+ };
12
+ const ensureQuonfigDir = async () => {
13
+ const dir = getQuonfigDir();
14
+ try {
15
+ await fs.promises.access(dir, fs.constants.F_OK);
16
+ }
17
+ catch {
18
+ await fs.promises.mkdir(dir, { recursive: true });
19
+ }
20
+ };
21
+ const loadStore = async () => {
22
+ try {
23
+ const data = await fs.promises.readFile(getGiteaTokenFile(), 'utf8');
24
+ return JSON.parse(data);
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ };
30
+ export const loadGiteaToken = async (workspaceId) => {
31
+ const store = await loadStore();
32
+ return store[workspaceId] ?? null;
33
+ };
34
+ export const saveGiteaToken = async (workspaceId, entry) => {
35
+ await ensureQuonfigDir();
36
+ const store = await loadStore();
37
+ store[workspaceId] = entry;
38
+ const targetFile = getGiteaTokenFile();
39
+ const tmpFile = `${targetFile}.tmp`;
40
+ await fs.promises.writeFile(tmpFile, JSON.stringify(store, null, 2), { encoding: 'utf8', mode: 0o600 });
41
+ await fs.promises.rename(tmpFile, targetFile);
42
+ };
43
+ export const isGiteaTokenExpired = (entry) => {
44
+ if (!entry.expiresAt)
45
+ return false;
46
+ return new Date(entry.expiresAt).getTime() - BUFFER_MS < Date.now();
47
+ };
@@ -14,6 +14,7 @@ export interface AuthConfig {
14
14
  [profileName: string]: {
15
15
  workspace: string;
16
16
  workspaceName?: string;
17
+ workspaceSlug?: string;
17
18
  };
18
19
  };
19
20
  }
@@ -20,7 +20,10 @@ const ensureQuonfigDir = async (options) => {
20
20
  };
21
21
  export const saveTokens = async (tokens, options) => {
22
22
  await ensureQuonfigDir(options);
23
- await fs.promises.writeFile(getTokenFile(options), JSON.stringify(tokens, null, 2), 'utf8');
23
+ const targetFile = getTokenFile(options);
24
+ const tmpFile = `${targetFile}.tmp`;
25
+ await fs.promises.writeFile(tmpFile, JSON.stringify(tokens, null, 2), { encoding: 'utf8', mode: 0o600 });
26
+ await fs.promises.rename(tmpFile, targetFile);
24
27
  };
25
28
  export const loadTokens = async (options) => {
26
29
  try {
@@ -45,9 +48,16 @@ export const saveAuthConfig = async (config, options) => {
45
48
  if (profileData.workspaceName) {
46
49
  configContent += ` # ${profileData.workspaceName}`;
47
50
  }
48
- configContent += '\n\n';
51
+ configContent += '\n';
52
+ if (profileData.workspaceSlug) {
53
+ configContent += `workspace_slug = ${profileData.workspaceSlug}\n`;
54
+ }
55
+ configContent += '\n';
49
56
  }
50
- await fs.promises.writeFile(getConfigFile(options), configContent, 'utf8');
57
+ const configTarget = getConfigFile(options);
58
+ const configTmp = `${configTarget}.tmp`;
59
+ await fs.promises.writeFile(configTmp, configContent, { encoding: 'utf8', mode: 0o600 });
60
+ await fs.promises.rename(configTmp, configTarget);
51
61
  };
52
62
  export const loadAuthConfig = async (options) => {
53
63
  try {
@@ -60,16 +70,20 @@ export const loadAuthConfig = async (options) => {
60
70
  if (defaultMatch && defaultMatch[1]) {
61
71
  config.defaultProfile = defaultMatch[1].trim();
62
72
  }
63
- // Parse profiles
64
- const profileRegex = /\[profile\s+(\w+)]\s*\n\s*workspace\s*=\s*([^\s#]+)(?:\s*#\s*(.+))?/g;
73
+ // Parse profiles — each block starts with [profile name] and contains key=value lines
74
+ const profileBlockRegex = /\[profile\s+(\w+)]\s*\n((?:[^\[]*\n?)*)/g;
65
75
  let match;
66
- while ((match = profileRegex.exec(data)) !== null) {
76
+ while ((match = profileBlockRegex.exec(data)) !== null) {
67
77
  const profileName = match[1];
68
- const workspace = match[2];
69
- const workspaceName = match[3]?.trim();
78
+ const block = match[2];
79
+ const workspaceMatch = block.match(/workspace\s*=\s*([^\s#]+)(?:\s*#\s*(.+))?/);
80
+ const slugMatch = block.match(/workspace_slug\s*=\s*(\S+)/);
81
+ if (!workspaceMatch)
82
+ continue;
70
83
  config.profiles[profileName] = {
71
- workspace,
72
- workspaceName,
84
+ workspace: workspaceMatch[1],
85
+ workspaceName: workspaceMatch[2]?.trim(),
86
+ workspaceSlug: slugMatch?.[1]?.trim(),
73
87
  };
74
88
  }
75
89
  return Object.keys(config.profiles).length > 0 ? config : null;
@@ -36,6 +36,7 @@ export interface ValidationStats {
36
36
  rules: number;
37
37
  segmentRefsChecked: number;
38
38
  schemaRefsChecked: number;
39
+ envRefsChecked: number;
39
40
  uniqueKeysVerified: number;
40
41
  }
41
42
  export interface ValidationResult {
@@ -202,6 +202,7 @@ export function validateWorkspace(workspaceDir) {
202
202
  rules: 0,
203
203
  segmentRefsChecked: 0,
204
204
  schemaRefsChecked: 0,
205
+ envRefsChecked: 0,
205
206
  uniqueKeysVerified: 0,
206
207
  };
207
208
  // Collect all configs for cross-reference checks
@@ -209,6 +210,7 @@ export function validateWorkspace(workspaceDir) {
209
210
  const allSchemaFiles = [];
210
211
  const segmentKeys = new Set();
211
212
  const schemaKeys = new Set();
213
+ const declaredEnvIds = new Set();
212
214
  // Check for unexpected top-level entries
213
215
  let entries;
214
216
  try {
@@ -238,6 +240,50 @@ export function validateWorkspace(workspaceDir) {
238
240
  });
239
241
  }
240
242
  }
243
+ // Validate quonfig.json at workspace root
244
+ const quonfigPath = path.join(workspaceDir, "quonfig.json");
245
+ if (!fs.existsSync(quonfigPath)) {
246
+ issues.push({
247
+ file: "quonfig.json",
248
+ message: `quonfig.json is missing from workspace root`,
249
+ severity: "error",
250
+ suggestion: `Create quonfig.json with format: {"environments": ["production", "staging"]}`,
251
+ });
252
+ }
253
+ else {
254
+ let quonfigRaw;
255
+ try {
256
+ quonfigRaw = fs.readFileSync(quonfigPath, "utf-8");
257
+ }
258
+ catch (err) {
259
+ quonfigRaw = "";
260
+ issues.push({ file: "quonfig.json", message: `Cannot read quonfig.json: ${err.message}`, severity: "error" });
261
+ }
262
+ if (quonfigRaw) {
263
+ let quonfigParsed;
264
+ try {
265
+ quonfigParsed = JSON.parse(quonfigRaw);
266
+ }
267
+ catch (err) {
268
+ quonfigParsed = null;
269
+ issues.push({ file: "quonfig.json", message: `Invalid JSON: ${err.message}`, severity: "error" });
270
+ }
271
+ const quonfigResult = z.object({ environments: z.array(z.string()) }).safeParse(quonfigParsed);
272
+ if (!quonfigResult.success) {
273
+ issues.push({
274
+ file: "quonfig.json",
275
+ message: `quonfig.json must have an "environments" array of strings`,
276
+ severity: "error",
277
+ suggestion: `Format: {"environments": ["production", "staging"]}`,
278
+ });
279
+ }
280
+ else {
281
+ for (const envId of quonfigResult.data.environments) {
282
+ declaredEnvIds.add(envId);
283
+ }
284
+ }
285
+ }
286
+ }
241
287
  // First pass: parse all files and collect keys
242
288
  for (const dir of KNOWN_DIRS) {
243
289
  const dirPath = path.join(workspaceDir, dir);
@@ -351,9 +397,9 @@ export function validateWorkspace(workspaceDir) {
351
397
  // Validate environment IDs are slugs, not UUIDs
352
398
  validateEnvironmentIds(config.environments, relPath, issues);
353
399
  // Validate rules
354
- validateRules(config.default.rules, relPath, "default", config.valueType, issues);
400
+ validateRules(config.default.rules, relPath, "default", config.valueType, issues, config.variants);
355
401
  for (const env of config.environments) {
356
- validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues);
402
+ validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues, config.variants);
357
403
  }
358
404
  // Count by type
359
405
  switch (config.type) {
@@ -435,6 +481,20 @@ export function validateWorkspace(workspaceDir) {
435
481
  });
436
482
  }
437
483
  }
484
+ // Check environment IDs referenced in config exist in quonfig.json
485
+ if (declaredEnvIds.size > 0) {
486
+ for (const env of config.environments) {
487
+ stats.envRefsChecked++;
488
+ if (!declaredEnvIds.has(env.id)) {
489
+ issues.push({
490
+ file: relPath,
491
+ message: `References environment "${env.id}" which is not declared in quonfig.json`,
492
+ severity: "error",
493
+ suggestion: `Add "${env.id}" to the environments array in quonfig.json, or remove this environment override`,
494
+ });
495
+ }
496
+ }
497
+ }
438
498
  }
439
499
  }
440
500
  // Check for duplicate keys across directories
@@ -479,7 +539,7 @@ function emptyStats() {
479
539
  return {
480
540
  configs: 0, featureFlags: 0, segments: 0, logLevels: 0, schemas: 0,
481
541
  environmentOverrides: 0, rules: 0, segmentRefsChecked: 0,
482
- schemaRefsChecked: 0, uniqueKeysVerified: 0,
542
+ schemaRefsChecked: 0, envRefsChecked: 0, uniqueKeysVerified: 0,
483
543
  };
484
544
  }
485
545
  // ── Validate from in-memory file map (for git hook) ─────────────────────
@@ -495,6 +555,35 @@ export function validateFileMap(files) {
495
555
  const allSchemaFiles = [];
496
556
  const allConfigs = [];
497
557
  const parsedConfigs = [];
558
+ const declaredEnvIds = new Set();
559
+ // Validate quonfig.json if it's included in the file map (it may not be in every commit)
560
+ const quonfigContent = files.get("quonfig.json");
561
+ if (quonfigContent !== undefined) {
562
+ let quonfigParsed;
563
+ try {
564
+ quonfigParsed = JSON.parse(quonfigContent);
565
+ }
566
+ catch (err) {
567
+ quonfigParsed = null;
568
+ issues.push({ file: "quonfig.json", message: `Invalid JSON: ${err.message}`, severity: "error" });
569
+ }
570
+ if (quonfigParsed !== null) {
571
+ const quonfigResult = z.object({ environments: z.array(z.string()) }).safeParse(quonfigParsed);
572
+ if (!quonfigResult.success) {
573
+ issues.push({
574
+ file: "quonfig.json",
575
+ message: `quonfig.json must have an "environments" array of strings`,
576
+ severity: "error",
577
+ suggestion: `Format: {"environments": ["production", "staging"]}`,
578
+ });
579
+ }
580
+ else {
581
+ for (const envId of quonfigResult.data.environments) {
582
+ declaredEnvIds.add(envId);
583
+ }
584
+ }
585
+ }
586
+ }
498
587
  for (const [relPath, content] of files) {
499
588
  const parts = relPath.split("/");
500
589
  if (parts.length < 2)
@@ -576,9 +665,9 @@ export function validateFileMap(files) {
576
665
  issues.push({ file: relPath, message: `Log level must have valueType "log_level", got "${config.valueType}"`, severity: "error" });
577
666
  }
578
667
  validateEnvironmentIds(config.environments, relPath, issues);
579
- validateRules(config.default.rules, relPath, "default", config.valueType, issues);
668
+ validateRules(config.default.rules, relPath, "default", config.valueType, issues, config.variants);
580
669
  for (const env of config.environments) {
581
- validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues);
670
+ validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues, config.variants);
582
671
  }
583
672
  allConfigs.push({ key: config.key, file: relPath });
584
673
  parsedConfigs.push({ relPath, config, dir });
@@ -604,6 +693,19 @@ export function validateFileMap(files) {
604
693
  suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
605
694
  });
606
695
  }
696
+ // Check environment IDs referenced in config exist in quonfig.json
697
+ if (declaredEnvIds.size > 0) {
698
+ for (const env of config.environments) {
699
+ if (!declaredEnvIds.has(env.id)) {
700
+ issues.push({
701
+ file: relPath,
702
+ message: `References environment "${env.id}" which is not declared in quonfig.json`,
703
+ severity: "error",
704
+ suggestion: `Add "${env.id}" to the environments array in quonfig.json, or remove this environment override`,
705
+ });
706
+ }
707
+ }
708
+ }
607
709
  }
608
710
  // Duplicate keys
609
711
  const keyToFiles = new Map();
@@ -674,10 +776,12 @@ function validateKey(key, file, issues) {
674
776
  issues.push({ file, message: `Key contains slash or backslash`, severity: "error" });
675
777
  }
676
778
  }
677
- function validateRules(rules, file, context, expectedValueType, issues) {
779
+ function validateRules(rules, file, context, expectedValueType, issues, variants = []) {
678
780
  if (rules.length === 0) {
679
781
  issues.push({ file, message: `${context}: no rules defined`, severity: "warning" });
680
782
  }
783
+ // Pre-compute serialized variant values for matching
784
+ const variantValues = variants.map(v => JSON.stringify(v.value));
681
785
  for (let i = 0; i < rules.length; i++) {
682
786
  const rule = rules[i];
683
787
  const ruleCtx = `${context}.rules[${i}]`;
@@ -692,6 +796,18 @@ function validateRules(rules, file, context, expectedValueType, issues) {
692
796
  severity: "error",
693
797
  });
694
798
  }
799
+ // Check that value matches one of the defined variants
800
+ if (variants.length > 0) {
801
+ const serialized = JSON.stringify(rule.value);
802
+ if (!variantValues.includes(serialized)) {
803
+ issues.push({
804
+ file,
805
+ message: `${ruleCtx}: value does not match any defined variant`,
806
+ severity: "error",
807
+ suggestion: `Rule value must be one of the defined variants`,
808
+ });
809
+ }
810
+ }
695
811
  }
696
812
  else if (rule.value.type === "weighted_values") {
697
813
  // Validate weighted values
@@ -707,6 +823,18 @@ function validateRules(rules, file, context, expectedValueType, issues) {
707
823
  severity: "error",
708
824
  });
709
825
  }
826
+ // Check that each weighted value matches one of the defined variants
827
+ if (variants.length > 0) {
828
+ const serialized = JSON.stringify(wv.weightedValues[j].value);
829
+ if (!variantValues.includes(serialized)) {
830
+ issues.push({
831
+ file,
832
+ message: `${ruleCtx}.weightedValues[${j}]: value does not match any defined variant`,
833
+ severity: "error",
834
+ suggestion: `Weighted value must be one of the defined variants`,
835
+ });
836
+ }
837
+ }
710
838
  }
711
839
  }
712
840
  // Check criteria
@@ -799,6 +927,8 @@ export function formatResult(result) {
799
927
  checks.push(`${s.segmentRefsChecked} segment refs`);
800
928
  if (s.schemaRefsChecked > 0)
801
929
  checks.push(`${s.schemaRefsChecked} schema refs`);
930
+ if (s.envRefsChecked > 0)
931
+ checks.push(`${s.envRefsChecked} env refs`);
802
932
  lines.push(` Checks: ${checks.join(", ")}`);
803
933
  // Group issues by file
804
934
  const byFile = new Map();