@quonfig/cli 0.3.0 → 0.5.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.
Files changed (49) hide show
  1. package/README.md +157 -75
  2. package/dist/agent/skill-bundle.generated.d.ts +1 -1
  3. package/dist/agent/skill-bundle.generated.js +4 -4
  4. package/dist/codegen/code-generators/base-typescript-generator.d.ts +1 -1
  5. package/dist/codegen/schema-extractor.d.ts +1 -1
  6. package/dist/commands/config-schema.js +15 -8
  7. package/dist/commands/create.d.ts +3 -1
  8. package/dist/commands/create.js +93 -20
  9. package/dist/commands/get.d.ts +1 -0
  10. package/dist/commands/get.js +40 -3
  11. package/dist/commands/info.js +10 -1
  12. package/dist/commands/set-default.d.ts +2 -0
  13. package/dist/commands/set-default.js +70 -18
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +35 -3
  16. package/dist/interactive-prompt.d.ts +18 -0
  17. package/dist/interactive-prompt.js +11 -2
  18. package/dist/migrate/quonfig-target/operators.js +4 -2
  19. package/dist/migrate/quonfig-target/ruleset.d.ts +48 -0
  20. package/dist/migrate/quonfig-target/ruleset.js +132 -8
  21. package/dist/migrate/sources/flagsmith/translate.js +11 -0
  22. package/dist/migrate/sources/launchdarkly/translate.js +19 -16
  23. package/dist/push/git-pack.d.ts +12 -0
  24. package/dist/push/git-pack.js +27 -3
  25. package/dist/push/run-push.d.ts +2 -1
  26. package/dist/push/run-push.js +1 -2
  27. package/dist/ui/get-string.d.ts +18 -1
  28. package/dist/ui/get-string.js +20 -5
  29. package/dist/ui/get-value.d.ts +3 -1
  30. package/dist/ui/get-value.js +7 -7
  31. package/dist/util/clone-and-stack-push.js +8 -3
  32. package/dist/util/encryption.js +11 -20
  33. package/dist/util/git-ops.d.ts +4 -2
  34. package/dist/util/git-ops.js +4 -2
  35. package/dist/util/redact.d.ts +56 -0
  36. package/dist/util/redact.js +98 -0
  37. package/dist/util/value-source.d.ts +30 -0
  38. package/dist/util/value-source.js +81 -0
  39. package/dist/verify/regex-dialect.d.ts +39 -0
  40. package/dist/verify/regex-dialect.js +278 -0
  41. package/dist/verify/standalone.d.ts +3 -1
  42. package/dist/verify/standalone.js +4 -19
  43. package/dist/verify/validate.js +70 -2
  44. package/dist/verify/value-grammar.d.ts +7 -0
  45. package/dist/verify/value-grammar.js +52 -0
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +1 -1
  48. package/oclif.manifest.json +236 -192
  49. package/package.json +18 -16
@@ -0,0 +1,39 @@
1
+ /**
2
+ * "Quonfig regex v1": the regex dialect of PROP_MATCHES and PROP_DOES_NOT_MATCH
3
+ * (qfg-ktkr.2). Spec: project/plans/2026-10-12-regex-write-time-check.md
4
+ * section 2, approved 2026-10-16 (qfg-goi1.10.10). Fixture of record:
5
+ * integration-test-data tests/regex/dialect.yaml (tag v2026.10.22.1; \_, \v
6
+ * and \x80-\xFF rejected per qfg-ktkr.7), which
7
+ * test/verify/regex-dialect.test.ts holds this file to, row by row.
8
+ *
9
+ * The dialect is the intersection of what every SDK's engine (go RE2, node
10
+ * RegExp, python re, ruby Onigmo, java.util.regex, .NET Regex) accepts and
11
+ * reads the same way, plus a ReDoS safety rule. SDKs do not validate; the
12
+ * writers (this check, app-quonfig's copy, and the Git hook built from this
13
+ * repo) are the only gate.
14
+ *
15
+ * No imports: src/verify/ is bundled into the standalone qfg-verify hook.
16
+ */
17
+ export declare const REGEX_V1_LIMITS: {
18
+ readonly maxLength: 256;
19
+ readonly maxRepeat: 1000;
20
+ readonly maxUnboundedQuantifiers: 2;
21
+ };
22
+ /** The canonical docs section the error links to (qfg-ktkr.5 owns the page). */
23
+ export declare const REGEX_V1_DOCS_URL = "https://docs.quonfig.com/docs/explanations/features/rules-and-segmentation#quonfig-regex-v1";
24
+ export type RegexV1Reason = 'backref' | 'bad-repeat' | 'class-syntax' | 'group-extension' | 'inline-flag' | 'lookaround' | 'named-group' | 'nested-quantifier' | 'octal' | 'possessive' | 'syntax' | 'too-long' | 'too-many-unbounded' | 'unicode-shorthand' | 'unsupported-escape';
25
+ /** Fix-it hints, verbatim from the fixture's `hints` map. */
26
+ export declare const REGEX_V1_HINTS: Record<RegexV1Reason, string>;
27
+ export interface RegexV1Violation {
28
+ hint: string;
29
+ reason: RegexV1Reason;
30
+ }
31
+ /**
32
+ * Check one pattern against Quonfig regex v1. Returns null when the pattern is
33
+ * in the dialect, or the reason and fix-it hint for the first violation.
34
+ *
35
+ * Precedence (fixture header): too-long first; then one left-to-right scan
36
+ * reports the first offending construct (an unclosed ( or [ is found at the
37
+ * end); then too-many-unbounded, which needs the whole pattern.
38
+ */
39
+ export declare function checkRegexV1(pattern: string): null | RegexV1Violation;
@@ -0,0 +1,278 @@
1
+ /**
2
+ * "Quonfig regex v1": the regex dialect of PROP_MATCHES and PROP_DOES_NOT_MATCH
3
+ * (qfg-ktkr.2). Spec: project/plans/2026-10-12-regex-write-time-check.md
4
+ * section 2, approved 2026-10-16 (qfg-goi1.10.10). Fixture of record:
5
+ * integration-test-data tests/regex/dialect.yaml (tag v2026.10.22.1; \_, \v
6
+ * and \x80-\xFF rejected per qfg-ktkr.7), which
7
+ * test/verify/regex-dialect.test.ts holds this file to, row by row.
8
+ *
9
+ * The dialect is the intersection of what every SDK's engine (go RE2, node
10
+ * RegExp, python re, ruby Onigmo, java.util.regex, .NET Regex) accepts and
11
+ * reads the same way, plus a ReDoS safety rule. SDKs do not validate; the
12
+ * writers (this check, app-quonfig's copy, and the Git hook built from this
13
+ * repo) are the only gate.
14
+ *
15
+ * No imports: src/verify/ is bundled into the standalone qfg-verify hook.
16
+ */
17
+ export const REGEX_V1_LIMITS = { maxLength: 256, maxRepeat: 1000, maxUnboundedQuantifiers: 2 };
18
+ /** The canonical docs section the error links to (qfg-ktkr.5 owns the page). */
19
+ export const REGEX_V1_DOCS_URL = 'https://docs.quonfig.com/docs/explanations/features/rules-and-segmentation#quonfig-regex-v1';
20
+ /** Fix-it hints, verbatim from the fixture's `hints` map. */
21
+ export const REGEX_V1_HINTS = {
22
+ 'too-long': 'Regex patterns are limited to 256 characters.',
23
+ syntax: 'This is not a valid regular expression. Check for an unbalanced ( ) or [ ], a trailing \\, a reversed range such as [z-a], or a quantifier with nothing before it (a quantifier cannot follow ^, $, |, ( or another quantifier).',
24
+ lookaround: 'Lookahead and lookbehind ((?=, (?!, (?<=, (?<!) are not supported. Rewrite the pattern without them, or split the rule into two criteria.',
25
+ 'named-group': "Named groups ((?<name>...), (?P<name>...), (?'name'...)) are not supported. Use a plain group ( ) or (?: ).",
26
+ 'inline-flag': 'Inline flags such as (?i) are not supported. Spell out both cases with a class, for example [Hh]ello.',
27
+ 'group-extension': 'Only ( ) and (?: ) groups are supported. Atomic groups (?>...), comments (?#...), conditionals and other (? forms are not.',
28
+ backref: 'Backreferences (\\1 to \\9, \\k<name>) are not supported. Repeat the text explicitly instead.',
29
+ octal: 'Octal escapes and \\0 are not supported. Write the character as \\xHH, for example \\x00.',
30
+ possessive: 'Possessive quantifiers (*+, ++, ?+, {n}+) are not supported. Use the plain form (a+) or the lazy form (a+?).',
31
+ 'bad-repeat': 'Write repeat counts as {n}, {n,} or {n,m} with n <= m <= 1000. {,m} is not portable: write {0,m}. Escape a literal brace as \\{.',
32
+ 'class-syntax': 'Inside [...] use only literals, ranges and escapes. POSIX classes such as [:digit:], a nested [, &&, --, ~~ and || are not supported. Escape a literal [ or ] as \\[ or \\], and do not start a class with ].',
33
+ 'unicode-shorthand': '\\d, \\w, \\s, \\b and their negations match different characters in different SDKs. Use [0-9], [A-Za-z0-9_] or [ \\t\\r\\n\\f\\x0B].',
34
+ 'unsupported-escape': 'This escape is not supported. The supported escapes are \\ followed by ASCII punctuation other than _, \\t \\n \\r \\f, and \\xHH with exactly two hex digits from \\x00 to \\x7F. Write _ unescaped instead of \\_, \\x0B instead of \\v, and a character above \\x7F as the character itself.',
35
+ 'nested-quantifier': 'A quantifier cannot apply to a group that contains a quantifier or a |, as in (a+)+ or (a|b)*. Such patterns can take exponential time. Quantify the inner part only, or use a class: [ab]* instead of (a|b)*.',
36
+ 'too-many-unbounded': 'A pattern can have at most 2 unbounded quantifiers (*, +, {n,}). Bound the extra ones with {n,m}, or split the rule into two criteria.',
37
+ };
38
+ // qfg-ktkr.7: no \_ (.NET throws), no \v (java reads a vertical-whitespace
39
+ // class), and \xHH only up to \x7F (ruby rejects \x80-\xFF).
40
+ const ASCII_PUNCT = '!"#$%&\'()*+,-./:;<=>?@[\\]^`{|}~';
41
+ const SIMPLE_ESCAPES = { f: 0x0c, n: 0x0a, r: 0x0d, t: 0x09 };
42
+ const MAX_HEX_ESCAPE = 0x7f;
43
+ const SHORTHANDS = 'dDwWsSbB';
44
+ const HEX = /^[\dA-Fa-f]{2}$/;
45
+ class Violation {
46
+ reason;
47
+ constructor(reason) {
48
+ this.reason = reason;
49
+ }
50
+ }
51
+ const fail = (reason) => {
52
+ throw new Violation(reason);
53
+ };
54
+ /**
55
+ * Check one pattern against Quonfig regex v1. Returns null when the pattern is
56
+ * in the dialect, or the reason and fix-it hint for the first violation.
57
+ *
58
+ * Precedence (fixture header): too-long first; then one left-to-right scan
59
+ * reports the first offending construct (an unclosed ( or [ is found at the
60
+ * end); then too-many-unbounded, which needs the whole pattern.
61
+ */
62
+ export function checkRegexV1(pattern) {
63
+ const cps = [...pattern];
64
+ try {
65
+ if (cps.length > REGEX_V1_LIMITS.maxLength)
66
+ fail('too-long');
67
+ const unbounded = scan(cps);
68
+ if (unbounded > REGEX_V1_LIMITS.maxUnboundedQuantifiers)
69
+ fail('too-many-unbounded');
70
+ return null;
71
+ }
72
+ catch (error) {
73
+ if (error instanceof Violation)
74
+ return { hint: REGEX_V1_HINTS[error.reason], reason: error.reason };
75
+ throw error;
76
+ }
77
+ }
78
+ /** Scan the pattern; returns the unbounded-quantifier count or throws a Violation. */
79
+ function scan(p) {
80
+ // One frame per open group: does it (transitively) contain a quantifier or |?
81
+ const groups = [];
82
+ let last = 'none';
83
+ let lastGroupRisky = false;
84
+ let unbounded = 0;
85
+ let i = 0;
86
+ const markRisky = () => {
87
+ if (groups.length > 0)
88
+ groups[groups.length - 1] = true;
89
+ };
90
+ /** At p[i] === '{': parse a repeat. Returns [end index, unbounded] or throws. */
91
+ const parseRepeat = () => {
92
+ let j = i + 1;
93
+ let n = '';
94
+ while (j < p.length && p[j] >= '0' && p[j] <= '9')
95
+ n += p[j++];
96
+ let m;
97
+ let comma = false;
98
+ if (p[j] === ',') {
99
+ comma = true;
100
+ j++;
101
+ m = '';
102
+ while (j < p.length && p[j] >= '0' && p[j] <= '9')
103
+ m += p[j++];
104
+ }
105
+ if (p[j] !== '}' || n === '')
106
+ fail('bad-repeat'); // {,m}, a bare {, x{foo}
107
+ const lo = Number(n);
108
+ const hi = comma && m !== '' ? Number(m) : undefined;
109
+ if (lo > REGEX_V1_LIMITS.maxRepeat || (hi !== undefined && (hi > REGEX_V1_LIMITS.maxRepeat || hi < lo)))
110
+ fail('bad-repeat');
111
+ return [j, comma && m === ''];
112
+ };
113
+ /** At p[i] === '\\': validate one escape, return its code point (or -1 for a non-literal). */
114
+ const parseEscape = () => {
115
+ const c = p[i + 1];
116
+ if (c === undefined)
117
+ fail('syntax');
118
+ if (c >= '1' && c <= '9')
119
+ fail('backref');
120
+ if (c === 'k')
121
+ fail('backref');
122
+ if (c === '0')
123
+ fail('octal');
124
+ if (SHORTHANDS.includes(c))
125
+ fail('unicode-shorthand');
126
+ if (c in SIMPLE_ESCAPES) {
127
+ i += 2;
128
+ return SIMPLE_ESCAPES[c];
129
+ }
130
+ if (c === 'x') {
131
+ const hex = (p[i + 2] ?? '') + (p[i + 3] ?? '');
132
+ if (!HEX.test(hex))
133
+ fail('unsupported-escape');
134
+ const cp = Number.parseInt(hex, 16);
135
+ if (cp > MAX_HEX_ESCAPE)
136
+ fail('unsupported-escape');
137
+ i += 4;
138
+ return cp;
139
+ }
140
+ if (c.length === 1 && ASCII_PUNCT.includes(c)) {
141
+ i += 2;
142
+ return c.codePointAt(0);
143
+ }
144
+ return fail('unsupported-escape');
145
+ };
146
+ const parseClass = () => {
147
+ // p[i] === '['
148
+ i++;
149
+ if (p[i] === '^')
150
+ i++;
151
+ if (p[i] === ']')
152
+ fail('class-syntax'); // []a] / [^]a] read differently in node
153
+ const start = i;
154
+ let prev; // code point of the previous single literal, for ranges
155
+ for (;;) {
156
+ const c = p[i];
157
+ if (c === undefined)
158
+ fail('syntax'); // unclosed [
159
+ if (c === ']') {
160
+ const body = p.slice(start, i);
161
+ if (body.length >= 2 && body[0] === ':' && body.at(-1) === ':')
162
+ fail('class-syntax'); // [:alpha:]
163
+ i++;
164
+ return;
165
+ }
166
+ if (c === '[')
167
+ fail('class-syntax'); // nested [ and [[:x:]]
168
+ if ('&-~|'.includes(c) && p[i + 1] === c)
169
+ fail('class-syntax'); // && -- ~~ ||
170
+ if (c === '-' && prev !== undefined && p[i + 1] !== ']' && p[i + 1] !== undefined) {
171
+ i++;
172
+ if (p[i] === '[')
173
+ fail('class-syntax');
174
+ const hi = p[i] === '\\' ? parseEscape() : p[i++].codePointAt(0);
175
+ if (hi < prev)
176
+ fail('syntax'); // [z-a]
177
+ prev = undefined;
178
+ continue;
179
+ }
180
+ prev = c === '\\' ? parseEscape() : p[i++].codePointAt(0);
181
+ }
182
+ };
183
+ while (i < p.length) {
184
+ const c = p[i];
185
+ switch (c) {
186
+ case '\\': {
187
+ parseEscape();
188
+ last = 'atom';
189
+ continue;
190
+ }
191
+ case '[': {
192
+ parseClass();
193
+ last = 'atom';
194
+ continue;
195
+ }
196
+ case '(': {
197
+ if (p[i + 1] === '?') {
198
+ const a = p[i + 2];
199
+ const b = p[i + 3];
200
+ if (a === ':') {
201
+ i += 3;
202
+ }
203
+ else if (a === '=' || a === '!' || (a === '<' && (b === '=' || b === '!'))) {
204
+ fail('lookaround');
205
+ }
206
+ else if (a === '<' || a === 'P' || a === "'") {
207
+ fail('named-group');
208
+ }
209
+ else if (a !== undefined && /[A-Za-z-]/.test(a)) {
210
+ fail('inline-flag');
211
+ }
212
+ else if (a === undefined) {
213
+ fail('syntax');
214
+ }
215
+ else {
216
+ fail('group-extension');
217
+ }
218
+ }
219
+ else {
220
+ i++;
221
+ }
222
+ groups.push(false);
223
+ last = 'none';
224
+ continue;
225
+ }
226
+ case ')': {
227
+ if (groups.length === 0)
228
+ fail('syntax');
229
+ lastGroupRisky = groups.pop();
230
+ if (lastGroupRisky)
231
+ markRisky(); // "contains" is transitive
232
+ last = 'group';
233
+ i++;
234
+ continue;
235
+ }
236
+ case '|': {
237
+ markRisky();
238
+ last = 'none';
239
+ break;
240
+ }
241
+ case '^':
242
+ case '$': {
243
+ last = 'none';
244
+ break;
245
+ }
246
+ case '*':
247
+ case '+':
248
+ case '?':
249
+ case '{': {
250
+ let end = i;
251
+ let isUnbounded = c === '*' || c === '+';
252
+ if (c === '{')
253
+ [end, isUnbounded] = parseRepeat();
254
+ if (last === 'none' || last === 'quantifier')
255
+ fail('syntax');
256
+ if (last === 'group' && lastGroupRisky)
257
+ fail('nested-quantifier');
258
+ markRisky();
259
+ if (isUnbounded)
260
+ unbounded++;
261
+ i = end + 1;
262
+ if (p[i] === '?')
263
+ i++; // lazy
264
+ else if (p[i] === '+')
265
+ fail('possessive');
266
+ last = 'quantifier';
267
+ continue;
268
+ }
269
+ default: {
270
+ last = 'atom';
271
+ }
272
+ }
273
+ i++;
274
+ }
275
+ if (groups.length > 0)
276
+ fail('syntax'); // unclosed (
277
+ return unbounded;
278
+ }
@@ -35,7 +35,9 @@ export declare function hookVersion(env?: HookEnv): string;
35
35
  * old oid that is an ancestor of the new oid. A delete and a create are both
36
36
  * rejected (provisioning creates `main` with auto_init; no writer creates it by
37
37
  * push, and the zero-oid door is the only way to delete-then-recreate).
38
- * Other refs are untouched. See plan 2026-09-17-tree-derived-cache 5.6.
38
+ * Other refs are untouched. Enforced on every repo, unconditionally: the
39
+ * QUONFIG_HOOK_FF_ENFORCE rollout switch is gone (qfg-jxml.29), so no env var
40
+ * can turn the rule off. See plan 2026-09-17-tree-derived-cache 5.6.
39
41
  */
40
42
  export declare function checkAppendOnlyMain(refs: readonly RefUpdate[], env: HookEnv, cwd?: string): {
41
43
  bypassReason?: string;
@@ -127,11 +127,13 @@ const REWRITE_OPTION = 'quonfig-rewrite=';
127
127
  * old oid that is an ancestor of the new oid. A delete and a create are both
128
128
  * rejected (provisioning creates `main` with auto_init; no writer creates it by
129
129
  * push, and the zero-oid door is the only way to delete-then-recreate).
130
- * Other refs are untouched. See plan 2026-09-17-tree-derived-cache 5.6.
130
+ * Other refs are untouched. Enforced on every repo, unconditionally: the
131
+ * QUONFIG_HOOK_FF_ENFORCE rollout switch is gone (qfg-jxml.29), so no env var
132
+ * can turn the rule off. See plan 2026-09-17-tree-derived-cache 5.6.
131
133
  */
132
134
  export function checkAppendOnlyMain(refs, env, cwd) {
133
135
  const mainUpdates = refs.filter((ref) => ref.refName === MAIN_REF);
134
- if (mainUpdates.length === 0 || !ffEnforcedForRepo(env))
136
+ if (mainUpdates.length === 0)
135
137
  return { errors: [] };
136
138
  const bypassReason = operatorBypassReason(env);
137
139
  if (bypassReason)
@@ -159,23 +161,6 @@ export function checkAppendOnlyMain(refs, env, cwd) {
159
161
  }
160
162
  return { errors };
161
163
  }
162
- /** Rollout switch: enforce only for repos named in the allowlist; `*` = all. */
163
- function ffEnforcedForRepo(env) {
164
- const allowlist = (env.QUONFIG_HOOK_FF_ENFORCE ?? '')
165
- .split(',')
166
- .map((entry) => entry.trim().toLowerCase())
167
- .filter(Boolean);
168
- if (allowlist.length === 0)
169
- return false;
170
- if (allowlist.includes('*'))
171
- return true;
172
- // Gitea 1.25 hands the hook its repo via these (modules/repository/env.go).
173
- const owner = (env.GITEA_REPO_USER_NAME ?? '').trim().toLowerCase();
174
- const name = (env.GITEA_REPO_NAME ?? '').trim().toLowerCase();
175
- if (!owner || !name)
176
- return false;
177
- return allowlist.includes(`${owner}/${name}`);
178
- }
179
164
  /**
180
165
  * Operator bypass, two keys, BOTH required: the pusher is the operator admin
181
166
  * account (the app pushes as admin too, so identity only excludes customers)
@@ -28,7 +28,8 @@
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 { checkRegexV1, REGEX_V1_DOCS_URL } from './regex-dialect.js';
32
+ 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
33
  // Inlined to keep this directory self-contained for the standalone bun-compile
33
34
  // build that runs as the qfg-verify pre-receive hook in app-gitea — that build
34
35
  // only copies cli/src/verify/, so any out-of-tree imports break it. Source of
@@ -139,10 +140,14 @@ const WeightedValuesSchema = z.object({
139
140
  }),
140
141
  });
141
142
  const RuleValueSchema = z.union([ValueSchema, WeightedValuesSchema]);
143
+ // IN_INT_RANGE's valueToMatch (qfg-goi1.5.9). Criterion-only: a config value
144
+ // can never be an int_range. Its shape is checked per operator below.
145
+ const IntRangeValueSchema = z.object({ type: z.literal('int_range'), value: z.unknown() });
146
+ const CriterionValueSchema = z.discriminatedUnion('type', [...ValueSchema.options, IntRangeValueSchema]);
142
147
  const CriterionSchema = z.object({
143
148
  propertyName: z.string().optional(),
144
149
  operator: OperatorSchema,
145
- valueToMatch: ValueSchema.optional(),
150
+ valueToMatch: CriterionValueSchema.optional(),
146
151
  });
147
152
  const ConfigRuleSchema = z.object({
148
153
  criteria: z.array(CriterionSchema),
@@ -1295,6 +1300,69 @@ function validateRules(rules, file, context, expectedValueType, issues, variants
1295
1300
  severity: 'error',
1296
1301
  });
1297
1302
  }
1303
+ const shapeIssue = criterionValueShapeIssue(criterion);
1304
+ if (shapeIssue) {
1305
+ issues.push({
1306
+ file,
1307
+ message: `${critCtx}: operator "${criterion.operator}" ${shapeIssue.problem}`,
1308
+ severity: 'error',
1309
+ suggestion: shapeIssue.hint,
1310
+ });
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ /**
1316
+ * The value shapes every SDK agrees to evaluate (qfg-goi1.5 R3-R5, Q5). A value
1317
+ * outside them is not an error in any SDK at eval time: the rule just never
1318
+ * matches, silently, and before qfg-goi1.5 some SDKs matched it anyway. Rejecting
1319
+ * at write time is the only place a human sees the mistake.
1320
+ */
1321
+ function criterionValueShapeIssue(criterion) {
1322
+ const vtm = criterion.valueToMatch;
1323
+ if (!vtm)
1324
+ return undefined;
1325
+ const shown = JSON.stringify(vtm.value);
1326
+ switch (criterion.operator) {
1327
+ case 'IN_INT_RANGE': {
1328
+ if (vtm.type !== 'int_range' || !isValidIntRange(vtm.value))
1329
+ return { hint: INT_RANGE_FORMAT_HINT, problem: `needs an int_range value; got ${vtm.type} ${shown}` };
1330
+ return undefined;
1331
+ }
1332
+ case 'PROP_AFTER':
1333
+ case 'PROP_BEFORE': {
1334
+ if (!['double', 'int', 'string'].includes(vtm.type) || !isValidTimestamp(vtm.value))
1335
+ return { hint: TIMESTAMP_FORMAT_HINT, problem: `needs a timestamp; got ${vtm.type} ${shown}` };
1336
+ return undefined;
1337
+ }
1338
+ case 'PROP_SEMVER_EQUAL':
1339
+ case 'PROP_SEMVER_GREATER_THAN':
1340
+ case 'PROP_SEMVER_LESS_THAN': {
1341
+ if (vtm.type !== 'string' || !isValidSemver(vtm.value))
1342
+ return { hint: SEMVER_FORMAT_HINT, problem: `needs a version string; got ${vtm.type} ${shown}` };
1343
+ return undefined;
1344
+ }
1345
+ case 'PROP_DOES_NOT_MATCH':
1346
+ case 'PROP_MATCHES': {
1347
+ // Quonfig regex v1 (qfg-ktkr.2): an out-of-dialect pattern compiles in
1348
+ // some SDKs and not others (or runs in exponential time), so the rule
1349
+ // silently diverges across SDKs. One check here covers qfg verify, the
1350
+ // Git hook and both push engines.
1351
+ if (vtm.type !== 'string' || typeof vtm.value !== 'string')
1352
+ return {
1353
+ hint: `Store the pattern as {"type": "string", "value": "<pattern>"}. See ${REGEX_V1_DOCS_URL}`,
1354
+ problem: `needs a string pattern; got ${vtm.type} ${shown}`,
1355
+ };
1356
+ const violation = checkRegexV1(vtm.value);
1357
+ if (violation)
1358
+ return {
1359
+ hint: `${violation.hint} See ${REGEX_V1_DOCS_URL}`,
1360
+ problem: `pattern ${shown} is outside Quonfig regex v1 (${violation.reason})`,
1361
+ };
1362
+ return undefined;
1363
+ }
1364
+ default: {
1365
+ return undefined;
1298
1366
  }
1299
1367
  }
1300
1368
  }
@@ -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.3.0";
1
+ declare const _default: "0.5.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.3.0';
2
+ export default '0.5.0';