@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.
- package/README.md +195 -60
- package/dist/agent/install-skill.d.ts +37 -0
- package/dist/agent/install-skill.js +110 -0
- package/dist/agent/skill-bundle.generated.d.ts +6 -0
- package/dist/agent/skill-bundle.generated.js +21 -0
- package/dist/codegen/json-schema-to-zod.js +99 -24
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +4 -2
- package/dist/commands/agent/install-skill.d.ts +12 -0
- package/dist/commands/agent/install-skill.js +83 -0
- package/dist/commands/cleanup/list.js +2 -1
- package/dist/commands/cleanup/remove.js +17 -6
- package/dist/commands/cleanup/status.js +2 -2
- package/dist/commands/cleanup.js +4 -3
- package/dist/commands/config-schema.js +9 -6
- package/dist/commands/contexts/search.d.ts +15 -0
- package/dist/commands/contexts/search.js +78 -0
- package/dist/commands/contexts.d.ts +6 -0
- package/dist/commands/contexts.js +10 -0
- package/dist/commands/set-default.js +5 -1
- package/dist/init/templates.js +17 -36
- package/dist/util/encryption.js +3 -1
- package/dist/verify/schema-error-reshape.d.ts +5 -0
- package/dist/verify/schema-error-reshape.js +23 -0
- package/dist/verify/validate.js +50 -2
- package/dist/verify/value-grammar.d.ts +7 -0
- package/dist/verify/value-grammar.js +52 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +239 -6
- package/package.json +2 -3
|
@@ -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
|
-
|
|
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
|
|
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
|
|
131
|
-
|
|
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,
|
|
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 "
|
|
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
|
|
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
|
|
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',
|
package/dist/commands/cleanup.js
CHANGED
|
@@ -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
|
|
8
|
-
|
|
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
|
|
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;
|
|
113
|
-
PROP_SEMVER_EQUAL string semver comparison;
|
|
114
|
-
PROP_SEMVER_GREATER_THAN string semver comparison;
|
|
115
|
-
PROP_BEFORE string (
|
|
116
|
-
PROP_AFTER string
|
|
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 = {
|
|
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,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',
|
package/dist/init/templates.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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 ────────────────────────────────────────────────────
|
package/dist/util/encryption.js
CHANGED
|
@@ -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;
|
package/dist/verify/validate.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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
|
+
export default '0.4.0';
|