@quonfig/cli 0.2.3 → 0.4.0

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.
@@ -1,10 +1,14 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { SKILL_NAME } from '../../agent/skill-bundle.generated.js';
4
5
  import { APICommand } from '../../index.js';
5
6
  import { getAppUrl } from '../../util/domain-urls.js';
6
7
  import nameArg from '../../util/name-arg.js';
7
- const SKILL_NAME = 'qfg-flag-cleanup';
8
+ // The public Quonfig agent skill (github.com/quonfig/skills, installed with
9
+ // `qfg agent install-skill` or the Claude Code plugin). Its
10
+ // references/retire-a-flag.md is the code-removal half of this workflow.
11
+ const SKILL_REFERENCE = 'references/retire-a-flag.md';
8
12
  const GITIGNORE_ENTRY = '.qf/cleanup/';
9
13
  // SDK client method names worth grepping for in the customer's codebase.
10
14
  // Based on the surfaces the official Quonfig SDKs expose (sdk-node /
@@ -121,14 +125,16 @@ function ensureGitignoreEntry(cwd) {
121
125
  }
122
126
  export default class CleanupRemove extends APICommand {
123
127
  static args = { ...nameArg };
124
- static description = `Write a cleanup payload for a ready-for-cleanup flag and hand off to the qfg-flag-cleanup Claude skill.
128
+ static description = `Write a cleanup payload for a ready-for-cleanup flag and hand off to the Quonfig agent skill.
125
129
 
126
130
  Modeled on \`qfg migrate my-code\` — this command never edits source files
127
131
  itself. It validates that the flag is marked readyForCleanup=true, refuses to
128
132
  proceed if there are still evals_2d > 0 (use --force to override), writes
129
133
  \`.qf/cleanup/<key>.json\` describing the flag's current rule shape +
130
- telemetry, and prints instructions to invoke the qfg-flag-cleanup skill which
131
- asks the engineer which value should "win" and applies the inlining.
134
+ telemetry, and prints how to have your coding agent remove the call sites with
135
+ the quonfig agent skill (its references/retire-a-flag.md), which asks the
136
+ engineer which value should "win" and applies the inlining. Install the skill
137
+ with \`qfg agent install-skill\` or \`claude plugin install quonfig@quonfig\`.
132
138
 
133
139
  The payload deliberately does NOT suggest a winning value; that's the
134
140
  engineer's call. Run \`qfg cleanup status <key>\` first if you want to inspect
@@ -208,9 +214,14 @@ telemetry before retiring.`;
208
214
  this.log(`Payload: ${relPayloadPath}`);
209
215
  this.log(`Evals (2d): ${totals.evals_2d}${payload.forced ? ' (--force overrode the safety gate)' : ''}`);
210
216
  this.log('');
211
- this.log(`To remove this flag's call sites, invoke Claude with the ${SKILL_NAME} skill:`);
217
+ this.log(`To remove this flag's call sites, ask your coding agent to retire it with the quonfig agent skill`);
218
+ this.log(`(${SKILL_REFERENCE} covers this workflow), e.g. in Claude Code:`);
212
219
  this.log('');
213
- this.log(` claude "/${SKILL_NAME} ${key}"`);
220
+ this.log(` claude "Retire the Quonfig flag ${key} using ${relPayloadPath}"`);
221
+ this.log('');
222
+ this.log(`No quonfig skill in this repo yet? Install it first:`);
223
+ this.log(` qfg agent install-skill # writes .claude/skills/quonfig/`);
224
+ this.log(` # or: claude plugin marketplace add quonfig/skills && claude plugin install quonfig@quonfig`);
214
225
  this.log('');
215
226
  this.log(`The skill will:`);
216
227
  this.log(` 1. Read ${relPayloadPath}`);
@@ -42,13 +42,13 @@ export default class CleanupStatus extends APICommand {
42
42
  static description = `Drill into one ready-for-cleanup flag — show telemetry across all environments and the current rule shape.
43
43
 
44
44
  Use this after \`qfg cleanup list\` to inspect a specific flag before handing
45
- removal off to the qfg-flag-cleanup Claude skill. The eval counts come from
45
+ removal off to the Quonfig agent skill. The eval counts come from
46
46
  analytics.configSparklines (the same backing data the per-flag sparklines on
47
47
  the flag detail page use), summed into 24h/2d/7d/30d windows so you can decide
48
48
  whether it's safe to retire.
49
49
 
50
50
  Pass --json for the structured object including the full rule shape per
51
- environment — the cleanup skill consumes this directly.`;
51
+ environment — the agent skill consumes this directly.`;
52
52
  static examples = [
53
53
  '<%= config.bin %> <%= command.id %> my.flag.key',
54
54
  '<%= config.bin %> <%= command.id %> my.flag.key --json',
@@ -4,14 +4,15 @@ export default class Cleanup extends BaseCommand {
4
4
 
5
5
  The cleanup workflow turns a flag's "Ready for cleanup" marker into an end-to-end
6
6
  removal flow: surface the flags that are safe to retire, hand the actual code
7
- removal to the qfg-flag-cleanup Claude skill, then delete the flag definition
8
- once the call sites are gone.
7
+ removal to your coding agent with the Quonfig agent skill (install it with
8
+ \`qfg agent install-skill\`), then delete the flag definition once the call
9
+ sites are gone.
9
10
 
10
11
  Lifecycle (high-level):
11
12
  1. Owner flips readyForCleanup=true in the UI (already exists)
12
13
  2. qfg cleanup list see candidates + telemetry
13
14
  3. qfg cleanup status <key> drill into one flag
14
- 4. qfg cleanup remove <key> handoff to the cleanup skill
15
+ 4. qfg cleanup remove <key> handoff to the agent skill
15
16
  5. PR merges, SDK redeploys, telemetry confirms 0 evals
16
17
  6. qfg cleanup verify <key> (optional) confirm safe
17
18
  7. qfg delete <key> remove the flag definition`;
@@ -89,6 +89,9 @@ CRITERION FIELDS
89
89
  Each criterion object has:
90
90
  operator (required) — see operator table below
91
91
  propertyName (required for PROP_* operators) — user property, e.g. "user.key", "user.plan"
92
+ To target specific customers or users, match on the context KEY
93
+ ("organization.key", "user.key"), not a slug, email or name.
94
+ Find a key from a name with: qfg contexts search organization <name>
92
95
  valueToMatch (required for all except ALWAYS_TRUE / IS_PRESENT / IS_NOT_PRESENT) — typed value to compare against
93
96
 
94
97
  ────────────────────────────────────────────────────────────
@@ -109,18 +112,18 @@ OPERATOR REFERENCE
109
112
  PROP_LESS_THAN_OR_EQUAL int or double
110
113
  PROP_GREATER_THAN int or double
111
114
  PROP_GREATER_THAN_OR_EQUAL int or double
112
- PROP_SEMVER_LESS_THAN string semver comparison; valueToMatch is a semver string like "4.0.9"
113
- PROP_SEMVER_EQUAL string semver comparison; valueToMatch is a semver string like "4.0.9"
114
- PROP_SEMVER_GREATER_THAN string semver comparison; valueToMatch is a semver string like "4.0.9"
115
- PROP_BEFORE string (ISO 8601) date/time comparison
116
- PROP_AFTER string (ISO 8601)
115
+ PROP_SEMVER_LESS_THAN string semver comparison; SemVer 2.0 like "4.0.9" or "1.0.0-beta.1"; "4" / "4.1" read as 4.0.0 / 4.1.0; no "v"
116
+ PROP_SEMVER_EQUAL string semver comparison; same version grammar
117
+ PROP_SEMVER_GREATER_THAN string semver comparison; same version grammar
118
+ PROP_BEFORE int or string epoch ms (int) or RFC3339 with Z/offset, e.g. "2025-01-01T00:00:00Z"; no date-only
119
+ PROP_AFTER int or string same timestamp grammar
117
120
  PROP_MATCHES string regex match
118
121
  PROP_DOES_NOT_MATCH string
119
122
  IS_PRESENT (none) propertyName is set on the evaluation context (no valueToMatch)
120
123
  IS_NOT_PRESENT (none) propertyName is missing from the evaluation context (no valueToMatch)
121
124
  IN_SEG string value = segment key (no propertyName needed)
122
125
  NOT_IN_SEG string value = segment key (no propertyName needed)
123
- IN_INT_RANGE int_range value = {min, max}
126
+ IN_INT_RANGE int_range value = {"start": 0, "end": 10}; [start, end); omit a bound for unbounded
124
127
  LOOKUP_KEY_IN string_list matches if SDK lookup key is in the list
125
128
  LOOKUP_KEY_NOT_IN string_list
126
129
 
@@ -0,0 +1,15 @@
1
+ import type { JsonObj } from '../../result.js';
2
+ import { APICommand } from '../../index.js';
3
+ export default class ContextsSearch extends APICommand {
4
+ static args: {
5
+ contextType: import("@oclif/core/lib/interfaces/parser.js").Arg<string, Record<string, unknown>>;
6
+ query: import("@oclif/core/lib/interfaces/parser.js").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static description: string;
9
+ static examples: string[];
10
+ static flags: {
11
+ environment: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
12
+ limit: import("@oclif/core/lib/interfaces/parser.js").OptionFlag<number, import("@oclif/core/lib/interfaces/parser.js").CustomOptions>;
13
+ };
14
+ run(): Promise<JsonObj | void>;
15
+ }
@@ -0,0 +1,78 @@
1
+ import { Args, Flags } from '@oclif/core';
2
+ import { APICommand } from '../../index.js';
3
+ export default class ContextsSearch extends APICommand {
4
+ static args = {
5
+ contextType: Args.string({
6
+ description: 'Context type to search, e.g. organization or user (the part before ".key" in a rule)',
7
+ required: true,
8
+ }),
9
+ query: Args.string({
10
+ description: 'Case-insensitive text matched against each context key and its reported properties (e.g. a name)',
11
+ required: true,
12
+ }),
13
+ };
14
+ static description = `Find a context's key from a name, to target it in a rule.
15
+
16
+ Targeting rules should match a context by its KEY (<contextType>.key, e.g.
17
+ organization.key or user.key), not by a slug, email or name. This searches the
18
+ contexts your SDKs have reported through telemetry, matching the query against
19
+ each context's key and properties, and prints each match's key, its name and
20
+ its email (blank when the context reported none), most recently seen first.
21
+
22
+ Only contexts your SDKs have reported are searchable. If nothing matches, the key
23
+ is unknown: get it from whoever owns that customer rather than targeting a slug
24
+ or email instead.
25
+
26
+ Pass a key as the query to see its name and email.
27
+
28
+ --json prints {contexts: [{key, name, email?}]}: name is null when the context
29
+ reported no name, and email is present only when it reported one.`;
30
+ static examples = [
31
+ '<%= config.bin %> <%= command.id %> organization formhealth',
32
+ '<%= config.bin %> <%= command.id %> user barry@example.com --environment production',
33
+ '<%= config.bin %> <%= command.id %> organization formhealth --json',
34
+ ];
35
+ static flags = {
36
+ environment: Flags.string({
37
+ description: 'Only contexts reported from this environment (default: every environment)',
38
+ }),
39
+ limit: Flags.integer({
40
+ default: 20,
41
+ description: 'Maximum number of matches (1-100)',
42
+ max: 100,
43
+ min: 1,
44
+ }),
45
+ };
46
+ async run() {
47
+ const { args, flags } = await this.parse(ContextsSearch);
48
+ const request = await this.apiClient.post('/api/v1/contexts/searchKeys', {
49
+ contextType: args.contextType,
50
+ environment: flags.environment,
51
+ limit: flags.limit,
52
+ query: args.query,
53
+ workspaceId: this.workspaceId,
54
+ });
55
+ if (!request.ok) {
56
+ const serverError = request.error;
57
+ const errorMsg = serverError?.error || serverError?.json?.message || `Failed to search contexts: ${request.status}`;
58
+ return this.err(errorMsg, { serverError: request.error });
59
+ }
60
+ const contexts = (request.json?.contexts ?? []);
61
+ if (contexts.length === 0) {
62
+ this.log(`No context seen via telemetry matched "${args.query}" (type ${args.contextType}), so the key is unknown.\n` +
63
+ 'Only contexts your SDKs have reported are searchable. Get the key from whoever owns that ' +
64
+ 'customer; do not target a slug or email instead.');
65
+ return { contexts: [] };
66
+ }
67
+ const keyWidth = Math.max(...contexts.map((c) => c.key.length), 'KEY'.length);
68
+ const nameWidth = Math.max(...contexts.map((c) => (c.name ?? '').length), 'NAME'.length);
69
+ const row = (key, name, email) => `${key.padEnd(keyWidth)} ${name.padEnd(nameWidth)} ${email}`.trimEnd();
70
+ const lines = [row('KEY', 'NAME', 'EMAIL')];
71
+ for (const context of contexts) {
72
+ lines.push(row(context.key, context.name ?? '', context.email ?? ''));
73
+ }
74
+ lines.push('', `Target these in a rule with propertyName "${args.contextType}.key".`);
75
+ this.log(lines.join('\n'));
76
+ return { contexts };
77
+ }
78
+ }
@@ -0,0 +1,6 @@
1
+ import { BaseCommand } from '../index.js';
2
+ export default class Contexts extends BaseCommand {
3
+ static description: string;
4
+ static examples: string[];
5
+ run(): Promise<void>;
6
+ }
@@ -0,0 +1,10 @@
1
+ import { BaseCommand } from '../index.js';
2
+ export default class Contexts extends BaseCommand {
3
+ static description = 'Look up contexts (organizations, users, ...) your SDKs have reported, to target them by key.';
4
+ static examples = ['<%= config.bin %> contexts search organization formhealth'];
5
+ async run() {
6
+ this.log('Use one of the contexts subcommands:');
7
+ this.log(' qfg contexts search CONTEXT_TYPE QUERY [--environment ENV] [--limit N] [--json]');
8
+ this.log(' # find a context key by name, e.g. qfg contexts search organization formhealth');
9
+ }
10
+ }
@@ -32,7 +32,11 @@ To set a percentage rollout (gradual rollout / A/B test / canary deploy) instead
32
32
  qfg set-rollout my.flag --environment production --true-percent 20
33
33
 
34
34
  To see all current values and rules for a flag:
35
- qfg info my.flag`;
35
+ qfg info my.flag
36
+
37
+ To target specific organizations or users, write rules on their KEY
38
+ (organization.key, user.key), not a slug or email; find a key from a name with:
39
+ qfg contexts search organization formhealth`;
36
40
  static examples = [
37
41
  '<%= config.bin %> <%= command.id %> my.flag.name # prompts for value and env',
38
42
  '<%= config.bin %> <%= command.id %> my.flag.name --value=true --environment=staging',
@@ -5,6 +5,16 @@
5
5
  * user-editable. Each starts with a comment so users know not to
6
6
  * hand-edit them.
7
7
  */
8
+ import { SKILL_FILES } from '../agent/skill-bundle.generated.js';
9
+ /**
10
+ * The qfg-vs-MCP steering and "setting a value keeps targeting" rules are
11
+ * shared with the Quonfig agent skill (github.com/quonfig/skills,
12
+ * references/writes.md). The skill is the single source; the cli vendors it
13
+ * at build time (scripts/sync-agent-skill.mjs) and AGENTS.md embeds it here.
14
+ */
15
+ const SHARED_WRITES_SECTION = SKILL_FILES.find((f) => f.path === 'references/writes.md')
16
+ .content.trim()
17
+ .replaceAll(/^## /gm, '### ');
8
18
  const MANAGED_HEADER = '<!-- Managed by `qfg init` — do not edit manually. Run `qfg init` to update. -->';
9
19
  // ── README.md ──────────────────────────────────────────────────────────
10
20
  export function readmeTemplate() {
@@ -196,48 +206,19 @@ This is a Quonfig workspace repository. See \`CLAUDE.md\` for the complete schem
196
206
  5. Files must be in the correct directory for their \`type\`.
197
207
  6. See \`CLAUDE.md\` for the full list of value types, operators, and constraints.
198
208
 
199
- ## \`qfg\` or the Quonfig MCP?
209
+ ## Hosted workspaces: \`qfg\`, the MCP, and how writes behave
200
210
 
201
211
  Only relevant if this workspace is *also* hosted on a Quonfig account. On the
202
212
  fully-local path, editing the JSON in this repo is the whole story.
203
213
 
204
- - **This repo is checked out and you have a shell** -> use \`qfg\`. The files are
205
- already on disk, so reading them costs nothing.
206
- - **Slack, claude.ai, or anywhere without a checkout** -> use the Quonfig MCP
207
- server. It reads and writes the hosted workspace directly. Do not try to shell
208
- out to \`qfg\` from a surface that has no repo.
209
-
210
- Both surfaces cover the same everyday loop — create a flag or config, change what
211
- an environment serves, set a log level. (\`qfg\` has all of it today; the MCP's
212
- create/config/log-level verbs land with its v2 write surface.)
213
-
214
- ## Setting a value KEEPS that environment's targeting rules
215
-
216
- Both surfaces write the same way, so learn it once:
217
-
218
- \`qfg set-default\` (alias \`toggle\`), \`qfg set-rollout\`, and the MCP
219
- \`set_flag\`/\`set_config\` verbs replace exactly ONE rule — the environment's
220
- **fallback**, the unconditional rule at the end of its rule list that decides
221
- what users receive when no targeting rule matches. Every targeting rule above
222
- it is kept, and both surfaces report how many. An environment with no rules of
223
- its own has them copied from default first, so inherited targeting is kept too.
224
-
225
- | Surface | By default | To set the value for EVERYONE |
226
- |---------|------------|-------------------------------|
227
- | \`qfg set-default\` / \`qfg set-rollout\` | Keeps targeting; reports "Kept N targeting rule(s)" | add \`--replace-targeting\` |
228
- | MCP \`set_flag\` / \`set_config\` | Keeps targeting; reports the kept count in its result | send \`replaceTargeting: true\` |
229
-
230
- Setting the value for everyone DELETES that environment's targeting rules. They
231
- stay in git history, and the write returns a \`previousCommitSha\` to restore from.
214
+ ${SHARED_WRITES_SECTION}
232
215
 
233
- **Still read before you write.** \`qfg info <key>\` (or MCP \`get_flag\`) shows the
234
- rules your value is about to sit next to. For anything a fallback cannot express —
235
- multi-rule targeting, reordering, editing one targeting rule — edit the JSON
236
- directly or use MCP \`set_document\`.
216
+ ## Application repos
237
217
 
238
- \`qfg log-level --target ...\` and MCP \`set_log_level\` are **surgical** in the same
239
- way: they upsert the level for the loggers you name and leave every other rule in
240
- place.
218
+ This file covers the workspace (config) repo. In an application repo that
219
+ *reads* these flags through a Quonfig SDK, install the Quonfig agent skill:
220
+ \`qfg agent install-skill\` (or \`claude plugin install quonfig@quonfig\`).
221
+ It covers SDK setup, creating and rolling out flags, and the MCP tools.
241
222
  `;
242
223
  }
243
224
  // ── Pre-commit hook ────────────────────────────────────────────────────
@@ -33,7 +33,9 @@ export function decrypt(encryptedString, keyStringHex) {
33
33
  const encryptedBytes = forge.util.hexToBytes(encryptedDataPart);
34
34
  const ivBytes = forge.util.hexToBytes(ivPart);
35
35
  const tagBytes = forge.util.hexToBytes(authTagPart);
36
- // Create decipher
36
+ // Create decipher. node-forge AES-GCM with the IV passed to start() below, not
37
+ // Node's deprecated IV-less crypto.createDecipher that this rule targets.
38
+ // nosemgrep: javascript.node-crypto.security.create-de-cipher-no-iv.create-de-cipher-no-iv
37
39
  const decipher = forge.cipher.createDecipher('AES-GCM', keyBytes);
38
40
  decipher.start({
39
41
  iv: ivBytes,
@@ -59,6 +59,11 @@ export declare function findOneOfTag(branches: unknown[], root: unknown): OneOfT
59
59
  * a single `oneOfTag` error at the union's position.
60
60
  */
61
61
  export declare function reshapeOneOfErrors<E extends SchemaErrorLike>(errors: readonly E[], rootSchema: unknown, value: unknown): (E | SchemaErrorLike)[];
62
+ /**
63
+ * The two 0-based indexes a duplicate-items violation names, lower first,
64
+ * or undefined for any other message. Lets a caller mark both rows.
65
+ */
66
+ export declare function duplicateItemIndexes(message: string): [number, number] | undefined;
62
67
  /** One error -> message. Undefined means "skip it, a nested error says why". */
63
68
  export declare function describeSchemaError(error: SchemaErrorLike): string | undefined;
64
69
  /**
@@ -207,6 +207,25 @@ export function reshapeOneOfErrors(errors, rootSchema, value) {
207
207
  });
208
208
  return result;
209
209
  }
210
+ /**
211
+ * `uniqueItems` wording. Ajv says `(items ## 3 and 1 are identical)`:
212
+ * 0-based, later item first, with a stray `##`. Lists are numbered from 1
213
+ * everywhere the user reads them, so name the pair by row number, lower
214
+ * first: `(items 2 and 4 are identical)`.
215
+ */
216
+ function duplicateItemsMessage(first, second) {
217
+ const [a, b] = [first, second].sort((x, y) => x - y);
218
+ return `must NOT have duplicate items (items ${a + 1} and ${b + 1} are identical)`;
219
+ }
220
+ const DUPLICATE_ITEMS_MESSAGE = /^must NOT have duplicate items \(items (\d+) and (\d+) are identical\)$/;
221
+ /**
222
+ * The two 0-based indexes a duplicate-items violation names, lower first,
223
+ * or undefined for any other message. Lets a caller mark both rows.
224
+ */
225
+ export function duplicateItemIndexes(message) {
226
+ const match = DUPLICATE_ITEMS_MESSAGE.exec(message);
227
+ return match ? [Number(match[1]) - 1, Number(match[2]) - 1] : undefined;
228
+ }
210
229
  function stringifyAllowed(entry) {
211
230
  return typeof entry === 'string' ? entry : JSON.stringify(entry);
212
231
  }
@@ -223,6 +242,10 @@ export function describeSchemaError(error) {
223
242
  return Array.isArray(params.allowedValues)
224
243
  ? `${message}: ${params.allowedValues.map(stringifyAllowed).join(', ')}`
225
244
  : message;
245
+ case 'uniqueItems':
246
+ return typeof params.i === 'number' && typeof params.j === 'number'
247
+ ? duplicateItemsMessage(params.i, params.j)
248
+ : message;
226
249
  case 'propertyNames':
227
250
  // The nested keyword error (which carries `propertyName`) says why.
228
251
  return undefined;
@@ -28,7 +28,7 @@
28
28
  import { z } from 'zod';
29
29
  import { findMapKeyOrderWarnings, navigate, scanOrderedJson } from './map-key-order.js';
30
30
  import { compileSchema, validateAgainstSchema } from './schema-validator.js';
31
- import { DURATION_FORMAT_HINT, isValidIsoDuration, parseDoubleValue, parseIntValue } from './value-grammar.js';
31
+ import { DURATION_FORMAT_HINT, INT_RANGE_FORMAT_HINT, isValidIntRange, isValidIsoDuration, isValidSemver, isValidTimestamp, parseDoubleValue, parseIntValue, SEMVER_FORMAT_HINT, TIMESTAMP_FORMAT_HINT, } from './value-grammar.js';
32
32
  // Inlined to keep this directory self-contained for the standalone bun-compile
33
33
  // build that runs as the qfg-verify pre-receive hook in app-gitea — that build
34
34
  // only copies cli/src/verify/, so any out-of-tree imports break it. Source of
@@ -139,10 +139,14 @@ const WeightedValuesSchema = z.object({
139
139
  }),
140
140
  });
141
141
  const RuleValueSchema = z.union([ValueSchema, WeightedValuesSchema]);
142
+ // IN_INT_RANGE's valueToMatch (qfg-goi1.5.9). Criterion-only: a config value
143
+ // can never be an int_range. Its shape is checked per operator below.
144
+ const IntRangeValueSchema = z.object({ type: z.literal('int_range'), value: z.unknown() });
145
+ const CriterionValueSchema = z.discriminatedUnion('type', [...ValueSchema.options, IntRangeValueSchema]);
142
146
  const CriterionSchema = z.object({
143
147
  propertyName: z.string().optional(),
144
148
  operator: OperatorSchema,
145
- valueToMatch: ValueSchema.optional(),
149
+ valueToMatch: CriterionValueSchema.optional(),
146
150
  });
147
151
  const ConfigRuleSchema = z.object({
148
152
  criteria: z.array(CriterionSchema),
@@ -1295,6 +1299,50 @@ function validateRules(rules, file, context, expectedValueType, issues, variants
1295
1299
  severity: 'error',
1296
1300
  });
1297
1301
  }
1302
+ const shapeIssue = criterionValueShapeIssue(criterion);
1303
+ if (shapeIssue) {
1304
+ issues.push({
1305
+ file,
1306
+ message: `${critCtx}: operator "${criterion.operator}" ${shapeIssue.problem}`,
1307
+ severity: 'error',
1308
+ suggestion: shapeIssue.hint,
1309
+ });
1310
+ }
1311
+ }
1312
+ }
1313
+ }
1314
+ /**
1315
+ * The value shapes every SDK agrees to evaluate (qfg-goi1.5 R3-R5, Q5). A value
1316
+ * outside them is not an error in any SDK at eval time: the rule just never
1317
+ * matches, silently, and before qfg-goi1.5 some SDKs matched it anyway. Rejecting
1318
+ * at write time is the only place a human sees the mistake.
1319
+ */
1320
+ function criterionValueShapeIssue(criterion) {
1321
+ const vtm = criterion.valueToMatch;
1322
+ if (!vtm)
1323
+ return undefined;
1324
+ const shown = JSON.stringify(vtm.value);
1325
+ switch (criterion.operator) {
1326
+ case 'IN_INT_RANGE': {
1327
+ if (vtm.type !== 'int_range' || !isValidIntRange(vtm.value))
1328
+ return { hint: INT_RANGE_FORMAT_HINT, problem: `needs an int_range value; got ${vtm.type} ${shown}` };
1329
+ return undefined;
1330
+ }
1331
+ case 'PROP_AFTER':
1332
+ case 'PROP_BEFORE': {
1333
+ if (!['double', 'int', 'string'].includes(vtm.type) || !isValidTimestamp(vtm.value))
1334
+ return { hint: TIMESTAMP_FORMAT_HINT, problem: `needs a timestamp; got ${vtm.type} ${shown}` };
1335
+ return undefined;
1336
+ }
1337
+ case 'PROP_SEMVER_EQUAL':
1338
+ case 'PROP_SEMVER_GREATER_THAN':
1339
+ case 'PROP_SEMVER_LESS_THAN': {
1340
+ if (vtm.type !== 'string' || !isValidSemver(vtm.value))
1341
+ return { hint: SEMVER_FORMAT_HINT, problem: `needs a version string; got ${vtm.type} ${shown}` };
1342
+ return undefined;
1343
+ }
1344
+ default: {
1345
+ return undefined;
1298
1346
  }
1299
1347
  }
1300
1348
  }
@@ -11,3 +11,10 @@ export declare const DURATION_FORMAT_HINT = "Expected an ISO 8601 duration such
11
11
  export declare const isValidIsoDuration: (value: string) => boolean;
12
12
  export declare const parseIntValue: (value: string) => number | undefined;
13
13
  export declare const parseDoubleValue: (value: string) => number | undefined;
14
+ export declare const TIMESTAMP_FORMAT_HINT = "Expected epoch milliseconds (a number or an all-digit string) or an RFC3339 timestamp with Z or an offset, such as 2025-01-01T00:00:00Z or 2025-01-01T09:00:00+09:00.";
15
+ export declare const isValidRfc3339: (value: string) => boolean;
16
+ export declare const isValidTimestamp: (value: unknown) => boolean;
17
+ export declare const SEMVER_FORMAT_HINT = "Expected a SemVer 2.0 version such as 4.0.18, 1.0.0-beta.1 or 1.0.0+build.5, or an abbreviated 4 / 4.1 (read as 4.0.0 / 4.1.0). No \"v\" prefix.";
18
+ export declare const isValidSemver: (value: unknown) => boolean;
19
+ export declare const INT_RANGE_FORMAT_HINT = "Expected {\"start\": <int>, \"end\": <int>} with at least one bound; the range is [start, end) and a missing bound is unbounded.";
20
+ export declare const isValidIntRange: (value: unknown) => boolean;
@@ -61,3 +61,55 @@ export const parseDoubleValue = (value) => {
61
61
  const double = Number(value);
62
62
  return Number.isFinite(double) ? double : undefined;
63
63
  };
64
+ // The ONE timestamp grammar for PROP_BEFORE / PROP_AFTER (qfg-goi1.5 R5),
65
+ // identical on the criterion and context sides in every SDK: a JSON number
66
+ // (epoch ms), an all-digit string (epoch ms), or RFC3339 with an uppercase T
67
+ // and a Z or numeric offset (fractional seconds allowed). Date-only,
68
+ // offset-less and free-form strings are not timestamps.
69
+ /* eslint-disable unicorn/better-regex */
70
+ const RFC3339_PATTERN = /^(?<y>[0-9]{4})-(?<mo>[0-9]{2})-(?<d>[0-9]{2})T(?<h>[0-9]{2}):(?<mi>[0-9]{2}):(?<s>[0-9]{2})(?:\.[0-9]+)?(?:Z|[+-](?<oh>[0-9]{2}):(?<om>[0-9]{2}))$/;
71
+ /* eslint-enable unicorn/better-regex */
72
+ export const TIMESTAMP_FORMAT_HINT = 'Expected epoch milliseconds (a number or an all-digit string) or an RFC3339 timestamp with Z or an offset, such as 2025-01-01T00:00:00Z or 2025-01-01T09:00:00+09:00.';
73
+ export const isValidRfc3339 = (value) => {
74
+ const g = RFC3339_PATTERN.exec(value)?.groups;
75
+ if (!g)
76
+ return false;
77
+ const [y, mo, d, h, mi, s] = [g.y, g.mo, g.d, g.h, g.mi, g.s].map(Number);
78
+ const daysInMonth = new Date(Date.UTC(y, mo, 0)).getUTCDate();
79
+ if (mo < 1 || mo > 12 || d < 1 || d > daysInMonth || h > 23 || mi > 59 || s > 59)
80
+ return false;
81
+ if (g.oh !== undefined && (Number(g.oh) > 23 || Number(g.om) > 59))
82
+ return false;
83
+ return true;
84
+ };
85
+ export const isValidTimestamp = (value) => {
86
+ if (typeof value === 'number')
87
+ return Number.isFinite(value);
88
+ if (typeof value !== 'string')
89
+ return false;
90
+ return parseIntValue(value) !== undefined || isValidRfc3339(value);
91
+ };
92
+ // The ONE version grammar for PROP_SEMVER_* (qfg-goi1.5 R4 + Q5): SemVer 2.0
93
+ // (no leading zeros, optional prerelease and build on a full x.y.z), plus the
94
+ // abbreviated forms "x" and "x.y", read as x.0.0 and x.y.0. A "v" prefix is
95
+ // not a version.
96
+ const NUM = '(?:0|[1-9][0-9]*)';
97
+ const PRE_ID = '(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)';
98
+ const BUILD_ID = '[0-9a-zA-Z-]+';
99
+ const SEMVER_PATTERN = new RegExp(`^${NUM}(?:\\.${NUM}(?:\\.${NUM}(?:-${PRE_ID}(?:\\.${PRE_ID})*)?(?:\\+${BUILD_ID}(?:\\.${BUILD_ID})*)?)?)?$`);
100
+ export const SEMVER_FORMAT_HINT = 'Expected a SemVer 2.0 version such as 4.0.18, 1.0.0-beta.1 or 1.0.0+build.5, or an abbreviated 4 / 4.1 (read as 4.0.0 / 4.1.0). No "v" prefix.';
101
+ export const isValidSemver = (value) => typeof value === 'string' && SEMVER_PATTERN.test(value);
102
+ // IN_INT_RANGE (qfg-goi1.5 R3): an object with integer start and/or end (at
103
+ // least one), each a safe integer or an all-digit string (the proto int64 JSON
104
+ // form). Range is [start, end); a missing bound is unbounded.
105
+ export const INT_RANGE_FORMAT_HINT = 'Expected {"start": <int>, "end": <int>} with at least one bound; the range is [start, end) and a missing bound is unbounded.';
106
+ export const isValidIntRange = (value) => {
107
+ if (!value || typeof value !== 'object' || Array.isArray(value))
108
+ return false;
109
+ const keys = Object.keys(value);
110
+ if (keys.length === 0 || keys.some((k) => k !== 'start' && k !== 'end'))
111
+ return false;
112
+ return Object.values(value).every((bound) => typeof bound === 'number'
113
+ ? Number.isSafeInteger(bound)
114
+ : typeof bound === 'string' && parseIntValue(bound) !== undefined);
115
+ };
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const _default: "0.2.3";
1
+ declare const _default: "0.4.0";
2
2
  export default _default;
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
2
- export default '0.2.3';
2
+ export default '0.4.0';