@quonfig/cli 0.1.0 → 0.2.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 +50 -50
- package/dist/codegen/json-schema-to-zod.js +127 -22
- package/dist/codegen/language-mappers/zod-base-mapper.d.ts +11 -1
- package/dist/codegen/language-mappers/zod-base-mapper.js +14 -2
- package/dist/codegen/language-mappers/zod-to-string-mapper.d.ts +2 -1
- package/dist/codegen/language-mappers/zod-to-string-mapper.js +6 -2
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.d.ts +6 -2
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +34 -5
- package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.d.ts +6 -3
- package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.js +22 -11
- package/dist/codegen/zod-introspection.d.ts +12 -0
- package/dist/codegen/zod-introspection.js +15 -0
- package/dist/commands/push.js +1 -1
- package/dist/commands/verify.js +1 -1
- package/dist/commands/workspace/bootstrap.js +1 -1
- package/dist/migrate/push-to-cloud.js +1 -1
- package/dist/util/git-ops.js +1 -1
- package/dist/verify/schema-error-reshape.d.ts +69 -0
- package/dist/verify/schema-error-reshape.js +257 -0
- package/dist/verify/schema-validator.d.ts +29 -0
- package/dist/verify/schema-validator.js +108 -0
- package/dist/verify/validate.d.ts +14 -2
- package/dist/verify/validate.js +92 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/* eslint-disable perfectionist/sort-interfaces, unicorn/prefer-string-replace-all, unicorn/no-array-callback-reference, unicorn/no-lonely-if --
|
|
2
|
+
* Copied from app-quonfig src/lib/domain/schema-error-reshape.ts (qfg-phcv).
|
|
3
|
+
* Only prettier's formatting differs; keep the logic identical to the app's
|
|
4
|
+
* copy rather than restyling it for this repo's lint rules. */
|
|
5
|
+
/**
|
|
6
|
+
* Ajv errors -> readable `{ path, message }` violations (qfg-q5f6.1).
|
|
7
|
+
*
|
|
8
|
+
* SELF-CONTAINED ON PURPOSE. This file has no imports: it is copied verbatim
|
|
9
|
+
* into `cli/src/verify/` so `qfg verify`, the app server and the browser
|
|
10
|
+
* (list badge, view page) word every violation the same way. Change it here
|
|
11
|
+
* first and re-copy; do not let the two drift.
|
|
12
|
+
*
|
|
13
|
+
* It works on any error object shaped like Ajv's `ErrorObject` (keyword,
|
|
14
|
+
* instancePath, schemaPath, params, message), from Ajv run with
|
|
15
|
+
* `allErrors: true`.
|
|
16
|
+
*
|
|
17
|
+
* Tagged `oneOf` (plan 2026-09-25-advanced-schema-for-jev.md, W1): Ajv
|
|
18
|
+
* reports every branch's failures, which for a three-way union is mostly
|
|
19
|
+
* noise. When every branch is an object with the same `const` (or one-value
|
|
20
|
+
* `enum`) property, that property is the tag:
|
|
21
|
+
* - the value's tag picks a branch -> only that branch's errors are kept;
|
|
22
|
+
* - the tag is missing or unknown -> ONE error:
|
|
23
|
+
* "`type` must be one of noul, score, choice".
|
|
24
|
+
* A `oneOf` without a tag is left as Ajv reported it.
|
|
25
|
+
*/
|
|
26
|
+
/** Keyword of the single error that replaces a missing/unknown-tag `oneOf`. */
|
|
27
|
+
export const ONE_OF_TAG_KEYWORD = 'oneOfTag';
|
|
28
|
+
function isObject(value) {
|
|
29
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
function unescapeToken(token) {
|
|
32
|
+
return token.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
33
|
+
}
|
|
34
|
+
function pointerTokens(pointer) {
|
|
35
|
+
const body = pointer.startsWith('#') ? pointer.slice(1) : pointer;
|
|
36
|
+
if (!body)
|
|
37
|
+
return [];
|
|
38
|
+
return body.slice(1).split('/').map(unescapeToken);
|
|
39
|
+
}
|
|
40
|
+
function resolvePointer(root, pointer) {
|
|
41
|
+
let node = root;
|
|
42
|
+
for (const token of pointerTokens(pointer)) {
|
|
43
|
+
if (Array.isArray(node))
|
|
44
|
+
node = node[Number(token)];
|
|
45
|
+
else if (isObject(node))
|
|
46
|
+
node = node[token];
|
|
47
|
+
else
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return node;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Ajv `instancePath` (JSON pointer) -> dotted path:
|
|
54
|
+
* `/questions/tone/criteria/1` -> `questions.tone.criteria[1]`.
|
|
55
|
+
* Walks the value so only real array indexes become `[n]`.
|
|
56
|
+
*/
|
|
57
|
+
export function instancePathToDotted(instancePath, value) {
|
|
58
|
+
if (!instancePath)
|
|
59
|
+
return '';
|
|
60
|
+
let path = '';
|
|
61
|
+
let node = value;
|
|
62
|
+
for (const token of pointerTokens(instancePath)) {
|
|
63
|
+
if (Array.isArray(node)) {
|
|
64
|
+
path += `[${token}]`;
|
|
65
|
+
node = node[Number(token)];
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
path += path ? `.${token}` : token;
|
|
69
|
+
node = isObject(node) ? node[token] : undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return path;
|
|
73
|
+
}
|
|
74
|
+
/** Follow local `$ref`s (`#/...`) a few hops; anything else is returned as is. */
|
|
75
|
+
function derefLocal(schema, root) {
|
|
76
|
+
let node = schema;
|
|
77
|
+
for (let hops = 0; hops < 8; hops += 1) {
|
|
78
|
+
if (!isObject(node) || typeof node.$ref !== 'string')
|
|
79
|
+
return node;
|
|
80
|
+
if (!node.$ref.startsWith('#'))
|
|
81
|
+
return node;
|
|
82
|
+
node = resolvePointer(root, node.$ref);
|
|
83
|
+
}
|
|
84
|
+
return node;
|
|
85
|
+
}
|
|
86
|
+
function tagValueOf(property) {
|
|
87
|
+
if (!isObject(property))
|
|
88
|
+
return undefined;
|
|
89
|
+
if ('const' in property)
|
|
90
|
+
return { value: property.const };
|
|
91
|
+
if (Array.isArray(property.enum) && property.enum.length === 1) {
|
|
92
|
+
return { value: property.enum[0] };
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Find the tag of a `oneOf`: the ONE property that is a `const` (or a
|
|
98
|
+
* one-value `enum`) in every branch, with distinct values. Undefined when
|
|
99
|
+
* there is none, or more than one candidate.
|
|
100
|
+
*/
|
|
101
|
+
export function findOneOfTag(branches, root) {
|
|
102
|
+
if (branches.length < 2)
|
|
103
|
+
return undefined;
|
|
104
|
+
const resolved = branches.map((branch) => derefLocal(branch, root));
|
|
105
|
+
const propertiesOf = resolved.map((branch) => isObject(branch) && isObject(branch.properties) ? branch.properties : undefined);
|
|
106
|
+
const first = propertiesOf[0];
|
|
107
|
+
if (!first || propertiesOf.some((props) => !props))
|
|
108
|
+
return undefined;
|
|
109
|
+
const candidates = [];
|
|
110
|
+
for (const name of Object.keys(first)) {
|
|
111
|
+
const values = [];
|
|
112
|
+
for (const props of propertiesOf) {
|
|
113
|
+
const tag = tagValueOf(derefLocal(props[name], root));
|
|
114
|
+
if (!tag)
|
|
115
|
+
break;
|
|
116
|
+
values.push(tag.value);
|
|
117
|
+
}
|
|
118
|
+
if (values.length !== branches.length)
|
|
119
|
+
continue;
|
|
120
|
+
const distinct = new Set(values.map((v) => JSON.stringify(v)));
|
|
121
|
+
if (distinct.size !== values.length)
|
|
122
|
+
continue;
|
|
123
|
+
candidates.push({ name, values });
|
|
124
|
+
}
|
|
125
|
+
return candidates.length === 1 ? candidates[0] : undefined;
|
|
126
|
+
}
|
|
127
|
+
function formatTagValue(value) {
|
|
128
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
129
|
+
}
|
|
130
|
+
function isUnderInstance(error, instancePath) {
|
|
131
|
+
return error.instancePath === instancePath || error.instancePath.startsWith(`${instancePath}/`);
|
|
132
|
+
}
|
|
133
|
+
/** Schema-path prefixes that identify errors raised inside branch `index`. */
|
|
134
|
+
function branchPrefixes(oneOfPath, branch, index) {
|
|
135
|
+
const prefixes = [`${oneOfPath}/${index}/`];
|
|
136
|
+
// A `{ "$ref": "#/$defs/x" }` branch reports its errors under the ref.
|
|
137
|
+
if (isObject(branch) && typeof branch.$ref === 'string') {
|
|
138
|
+
if (branch.$ref.startsWith('#'))
|
|
139
|
+
prefixes.push(`${branch.$ref}/`);
|
|
140
|
+
}
|
|
141
|
+
return prefixes;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Collapse tagged `oneOf` noise. Returns a new list in Ajv's order: errors
|
|
145
|
+
* from non-selected branches are dropped, and a missing/unknown tag becomes
|
|
146
|
+
* a single `oneOfTag` error at the union's position.
|
|
147
|
+
*/
|
|
148
|
+
export function reshapeOneOfErrors(errors, rootSchema, value) {
|
|
149
|
+
const dropped = new Set();
|
|
150
|
+
const replaced = new Map();
|
|
151
|
+
errors.forEach((error, index) => {
|
|
152
|
+
if (error.keyword !== 'oneOf')
|
|
153
|
+
return;
|
|
154
|
+
// More than one branch passed: that is not a tagging problem.
|
|
155
|
+
const passing = error.params.passingSchemas;
|
|
156
|
+
if (passing !== null && passing !== undefined)
|
|
157
|
+
return;
|
|
158
|
+
const branches = resolvePointer(rootSchema, error.schemaPath);
|
|
159
|
+
if (!Array.isArray(branches))
|
|
160
|
+
return;
|
|
161
|
+
const tag = findOneOfTag(branches, rootSchema);
|
|
162
|
+
if (!tag)
|
|
163
|
+
return;
|
|
164
|
+
const members = [];
|
|
165
|
+
const prefixes = branches.map((branch, i) => branchPrefixes(error.schemaPath, branch, i));
|
|
166
|
+
errors.forEach((other, otherIndex) => {
|
|
167
|
+
if (otherIndex === index)
|
|
168
|
+
return;
|
|
169
|
+
if (!isUnderInstance(other, error.instancePath))
|
|
170
|
+
return;
|
|
171
|
+
const branch = prefixes.findIndex((list) => list.some((prefix) => other.schemaPath.startsWith(prefix)));
|
|
172
|
+
if (branch >= 0)
|
|
173
|
+
members.push({ index: otherIndex, branch });
|
|
174
|
+
});
|
|
175
|
+
const instance = resolvePointer(value, error.instancePath);
|
|
176
|
+
const selected = isObject(instance) && tag.name in instance
|
|
177
|
+
? tag.values.findIndex((v) => JSON.stringify(v) === JSON.stringify(instance[tag.name]))
|
|
178
|
+
: -1;
|
|
179
|
+
if (selected < 0) {
|
|
180
|
+
for (const member of members)
|
|
181
|
+
dropped.add(member.index);
|
|
182
|
+
const allowed = tag.values.map(formatTagValue).join(', ');
|
|
183
|
+
replaced.set(index, {
|
|
184
|
+
keyword: ONE_OF_TAG_KEYWORD,
|
|
185
|
+
instancePath: error.instancePath,
|
|
186
|
+
schemaPath: error.schemaPath,
|
|
187
|
+
params: { tag: tag.name, allowedValues: tag.values },
|
|
188
|
+
message: isObject(instance) ? `\`${tag.name}\` must be one of ${allowed}` : 'must be object',
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const kept = members.filter((member) => member.branch === selected);
|
|
193
|
+
for (const member of members) {
|
|
194
|
+
if (member.branch !== selected)
|
|
195
|
+
dropped.add(member.index);
|
|
196
|
+
}
|
|
197
|
+
// Keep Ajv's "must match exactly one schema" only if the selected branch
|
|
198
|
+
// reported nothing we could attribute to it (e.g. a remote `$ref`).
|
|
199
|
+
if (kept.length > 0)
|
|
200
|
+
dropped.add(index);
|
|
201
|
+
});
|
|
202
|
+
const result = [];
|
|
203
|
+
errors.forEach((error, index) => {
|
|
204
|
+
if (dropped.has(index))
|
|
205
|
+
return;
|
|
206
|
+
result.push(replaced.get(index) ?? error);
|
|
207
|
+
});
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
function stringifyAllowed(entry) {
|
|
211
|
+
return typeof entry === 'string' ? entry : JSON.stringify(entry);
|
|
212
|
+
}
|
|
213
|
+
/** One error -> message. Undefined means "skip it, a nested error says why". */
|
|
214
|
+
export function describeSchemaError(error) {
|
|
215
|
+
const params = error.params;
|
|
216
|
+
const message = error.message ?? `failed ${error.keyword}`;
|
|
217
|
+
switch (error.keyword) {
|
|
218
|
+
case 'additionalProperties':
|
|
219
|
+
return `must NOT have additional property '${String(params.additionalProperty)}'`;
|
|
220
|
+
case 'unevaluatedProperties':
|
|
221
|
+
return `must NOT have unevaluated property '${String(params.unevaluatedProperty)}'`;
|
|
222
|
+
case 'enum':
|
|
223
|
+
return Array.isArray(params.allowedValues)
|
|
224
|
+
? `${message}: ${params.allowedValues.map(stringifyAllowed).join(', ')}`
|
|
225
|
+
: message;
|
|
226
|
+
case 'propertyNames':
|
|
227
|
+
// The nested keyword error (which carries `propertyName`) says why.
|
|
228
|
+
return undefined;
|
|
229
|
+
default:
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
if (error.propertyName !== undefined) {
|
|
233
|
+
return `property name '${error.propertyName}': ${message}`;
|
|
234
|
+
}
|
|
235
|
+
return message;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Ajv errors for `value` against `rootSchema` -> violations, with tagged
|
|
239
|
+
* `oneOf` noise collapsed. Call it only when validation failed: it never
|
|
240
|
+
* returns an empty list.
|
|
241
|
+
*/
|
|
242
|
+
export function schemaErrorsToViolations(errors, rootSchema, value) {
|
|
243
|
+
const violations = [];
|
|
244
|
+
for (const error of reshapeOneOfErrors(errors, rootSchema, value)) {
|
|
245
|
+
const message = describeSchemaError(error);
|
|
246
|
+
if (message === undefined)
|
|
247
|
+
continue;
|
|
248
|
+
violations.push({
|
|
249
|
+
path: instancePathToDotted(error.instancePath, value),
|
|
250
|
+
message,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (violations.length === 0) {
|
|
254
|
+
violations.push({ path: '', message: 'does not match the schema' });
|
|
255
|
+
}
|
|
256
|
+
return violations;
|
|
257
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type SchemaViolation } from './schema-error-reshape.js';
|
|
2
|
+
export type { SchemaViolation } from './schema-error-reshape.js';
|
|
3
|
+
/** Formats the app treats as editor hints; they accept any string. */
|
|
4
|
+
export declare const ALWAYS_VALID_FORMATS: string[];
|
|
5
|
+
type ValidateFn = ((value: unknown) => boolean) & {
|
|
6
|
+
errors?: null | unknown[];
|
|
7
|
+
};
|
|
8
|
+
export type CompiledSchema = {
|
|
9
|
+
ok: false;
|
|
10
|
+
reason: string;
|
|
11
|
+
} | {
|
|
12
|
+
ok: true;
|
|
13
|
+
validate: ValidateFn;
|
|
14
|
+
};
|
|
15
|
+
/** Compile (cached per schema object) a validator. Never throws. */
|
|
16
|
+
export declare function compileSchema(schema: Record<string, unknown>): CompiledSchema;
|
|
17
|
+
export type SchemaValidationResult = {
|
|
18
|
+
kind: 'invalid-schema';
|
|
19
|
+
ok: false;
|
|
20
|
+
reason: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'violations';
|
|
23
|
+
ok: false;
|
|
24
|
+
violations: SchemaViolation[];
|
|
25
|
+
} | {
|
|
26
|
+
ok: true;
|
|
27
|
+
};
|
|
28
|
+
/** Validate `value` against `schema`. Never throws. */
|
|
29
|
+
export declare function validateAgainstSchema(schema: Record<string, unknown>, value: unknown): SchemaValidationResult;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema validation for schema-bound config values (qfg-phcv).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors app-quonfig src/lib/domain/schema-validator.ts so `qfg verify` and
|
|
5
|
+
* the app give the same verdict on the same value. Rules (decided in
|
|
6
|
+
* project/plans/2026-09-25-advanced-schema-for-jev.md W1/W6):
|
|
7
|
+
* - Draft 2020-12 by default. A `$schema` naming draft-07 (http or https,
|
|
8
|
+
* with or without `#`) uses a separate draft-07 Ajv instance, because
|
|
9
|
+
* draft-07 tuple `items: [...]` does not compile on Ajv2020. Draft-06 and
|
|
10
|
+
* older are a compile error.
|
|
11
|
+
* - A schema that does not compile is an error, never a skipped validation.
|
|
12
|
+
* - `format` is enforced through ajv-formats. The app's long-text editor
|
|
13
|
+
* hints (`textarea`, `multiline`, `multi-line`) are always-valid formats.
|
|
14
|
+
* - `addUsedSchema: false`: two schemas declaring the same `$id` must not
|
|
15
|
+
* collide in the shared Ajv instance.
|
|
16
|
+
*
|
|
17
|
+
* Lives inside cli/src/verify/ because the app-gitea hook build only copies
|
|
18
|
+
* this directory (see validate.ts). The hook does not run value validation
|
|
19
|
+
* yet (phase 1b), but it compiles this file, so app-gitea/Dockerfile must
|
|
20
|
+
* install `ajv` and `ajv-formats`.
|
|
21
|
+
*
|
|
22
|
+
* Imports: the CLI is ESM with nodenext resolution and Ajv ships CommonJS, so
|
|
23
|
+
* the default import may be the class or the module object depending on the
|
|
24
|
+
* runtime (node vs bun). `ajv/dist/2020.js` is imported by explicit path; a
|
|
25
|
+
* bare `ajv` must resolve to the direct v8 dependency, not the v6 eslint
|
|
26
|
+
* hoists.
|
|
27
|
+
*/
|
|
28
|
+
import AjvDraft07Import from 'ajv';
|
|
29
|
+
import Ajv2020Import from 'ajv/dist/2020.js';
|
|
30
|
+
import addFormatsImport from 'ajv-formats';
|
|
31
|
+
import { schemaErrorsToViolations } from './schema-error-reshape.js';
|
|
32
|
+
/** Formats the app treats as editor hints; they accept any string. */
|
|
33
|
+
export const ALWAYS_VALID_FORMATS = ['textarea', 'multiline', 'multi-line'];
|
|
34
|
+
const DRAFT_07_SCHEMA_URL = /^https?:\/\/json-schema\.org\/draft-07\/schema#?$/;
|
|
35
|
+
const DRAFT_07_CANONICAL_URL = 'http://json-schema.org/draft-07/schema#';
|
|
36
|
+
const PRE_DRAFT_07_SCHEMA_URL = /^https?:\/\/json-schema\.org\/(draft-0[0-6]\/)?schema#?$/;
|
|
37
|
+
const AJV_OPTIONS = {
|
|
38
|
+
addUsedSchema: false,
|
|
39
|
+
allErrors: true,
|
|
40
|
+
strict: false,
|
|
41
|
+
};
|
|
42
|
+
function interop(imported) {
|
|
43
|
+
const mod = imported;
|
|
44
|
+
return (typeof mod === 'function' ? mod : (mod.default ?? mod));
|
|
45
|
+
}
|
|
46
|
+
const AjvDraft07 = interop(AjvDraft07Import);
|
|
47
|
+
const Ajv2020 = interop(Ajv2020Import);
|
|
48
|
+
const addFormats = interop(addFormatsImport);
|
|
49
|
+
function configure(ajv) {
|
|
50
|
+
addFormats(ajv);
|
|
51
|
+
for (const format of ALWAYS_VALID_FORMATS)
|
|
52
|
+
ajv.addFormat(format, true);
|
|
53
|
+
return ajv;
|
|
54
|
+
}
|
|
55
|
+
let ajv2020;
|
|
56
|
+
let ajvDraft07;
|
|
57
|
+
function getAjv2020() {
|
|
58
|
+
ajv2020 ??= configure(new Ajv2020(AJV_OPTIONS));
|
|
59
|
+
return ajv2020;
|
|
60
|
+
}
|
|
61
|
+
function getAjvDraft07() {
|
|
62
|
+
ajvDraft07 ??= configure(new AjvDraft07(AJV_OPTIONS));
|
|
63
|
+
return ajvDraft07;
|
|
64
|
+
}
|
|
65
|
+
const compiledByObject = new WeakMap();
|
|
66
|
+
function compileUncached(schema) {
|
|
67
|
+
const declared = schema.$schema;
|
|
68
|
+
let ajv = getAjv2020();
|
|
69
|
+
let toCompile = schema;
|
|
70
|
+
if (typeof declared === 'string') {
|
|
71
|
+
if (DRAFT_07_SCHEMA_URL.test(declared)) {
|
|
72
|
+
ajv = getAjvDraft07();
|
|
73
|
+
// Ajv only knows the draft-07 meta-schema by its canonical id; the
|
|
74
|
+
// https and no-`#` spellings name the same draft.
|
|
75
|
+
toCompile = { ...schema, $schema: DRAFT_07_CANONICAL_URL };
|
|
76
|
+
}
|
|
77
|
+
else if (PRE_DRAFT_07_SCHEMA_URL.test(declared)) {
|
|
78
|
+
return { ok: false, reason: `unsupported $schema ${declared}; use draft 2020-12 or draft-07` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
return { ok: true, validate: ajv.compile(toCompile) };
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
86
|
+
// Ajv's meta-schema failure already starts with "schema is invalid: ".
|
|
87
|
+
return { ok: false, reason: message.replace(/^schema is invalid: /, '') };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Compile (cached per schema object) a validator. Never throws. */
|
|
91
|
+
export function compileSchema(schema) {
|
|
92
|
+
let compiled = compiledByObject.get(schema);
|
|
93
|
+
if (!compiled) {
|
|
94
|
+
compiled = compileUncached(schema);
|
|
95
|
+
compiledByObject.set(schema, compiled);
|
|
96
|
+
}
|
|
97
|
+
return compiled;
|
|
98
|
+
}
|
|
99
|
+
/** Validate `value` against `schema`. Never throws. */
|
|
100
|
+
export function validateAgainstSchema(schema, value) {
|
|
101
|
+
const compiled = compileSchema(schema);
|
|
102
|
+
if (!compiled.ok)
|
|
103
|
+
return { kind: 'invalid-schema', ok: false, reason: compiled.reason };
|
|
104
|
+
if (compiled.validate(value))
|
|
105
|
+
return { ok: true };
|
|
106
|
+
const errors = (compiled.validate.errors ?? []);
|
|
107
|
+
return { kind: 'violations', ok: false, violations: schemaErrorsToViolations(errors, schema, value) };
|
|
108
|
+
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* - Log level constraints (valueType=log_level)
|
|
17
17
|
* - Referential integrity (IN_SEG/NOT_IN_SEG reference existing segments)
|
|
18
18
|
* - schemaKey references existing schemas
|
|
19
|
+
* - Schema-bound JSON values match their schema (Ajv, opt-in via
|
|
20
|
+
* `validateValues`; see validateBoundJsonValues)
|
|
19
21
|
* - Rule structure (criteria + value present)
|
|
20
22
|
* - Value type consistency
|
|
21
23
|
* - Weighted values non-empty and consistent
|
|
@@ -41,6 +43,16 @@ export interface ValidationStats {
|
|
|
41
43
|
segments: number;
|
|
42
44
|
uniqueKeysVerified: number;
|
|
43
45
|
}
|
|
46
|
+
export interface ValidateOptions {
|
|
47
|
+
/**
|
|
48
|
+
* Validate every schema-bound JSON value against its schema with Ajv
|
|
49
|
+
* (qfg-phcv). The CLI callers (`qfg verify`, `qfg push`, `qfg workspace
|
|
50
|
+
* bootstrap`, `qfg migrate --push`) turn it on. The pre-receive hook
|
|
51
|
+
* (standalone.ts) leaves it OFF until phase 1b (plan
|
|
52
|
+
* 2026-09-25-advanced-schema-for-jev.md W1b).
|
|
53
|
+
*/
|
|
54
|
+
validateValues?: boolean;
|
|
55
|
+
}
|
|
44
56
|
export interface ValidationResult {
|
|
45
57
|
filesChecked: number;
|
|
46
58
|
issues: ValidationIssue[];
|
|
@@ -52,11 +64,11 @@ export declare const KNOWN_DIRS: Set<string>;
|
|
|
52
64
|
/**
|
|
53
65
|
* Validate an entire workspace directory on disk.
|
|
54
66
|
*/
|
|
55
|
-
export declare function validateWorkspace(workspaceDir: string): ValidationResult;
|
|
67
|
+
export declare function validateWorkspace(workspaceDir: string, options?: ValidateOptions): ValidationResult;
|
|
56
68
|
/**
|
|
57
69
|
* Validate configs provided as a map of { "dir/file.json": jsonString }.
|
|
58
70
|
* Used by the pre-receive hook which reads files from git objects.
|
|
59
71
|
*/
|
|
60
|
-
export declare function validateFileMap(files: Map<string, string
|
|
72
|
+
export declare function validateFileMap(files: Map<string, string>, options?: ValidateOptions): ValidationResult;
|
|
61
73
|
export declare function validateKey(key: string, file: string, issues: ValidationIssue[]): void;
|
|
62
74
|
export declare function formatResult(result: ValidationResult): string;
|
package/dist/verify/validate.js
CHANGED
|
@@ -16,11 +16,14 @@
|
|
|
16
16
|
* - Log level constraints (valueType=log_level)
|
|
17
17
|
* - Referential integrity (IN_SEG/NOT_IN_SEG reference existing segments)
|
|
18
18
|
* - schemaKey references existing schemas
|
|
19
|
+
* - Schema-bound JSON values match their schema (Ajv, opt-in via
|
|
20
|
+
* `validateValues`; see validateBoundJsonValues)
|
|
19
21
|
* - Rule structure (criteria + value present)
|
|
20
22
|
* - Value type consistency
|
|
21
23
|
* - Weighted values non-empty and consistent
|
|
22
24
|
*/
|
|
23
25
|
import { z } from 'zod';
|
|
26
|
+
import { compileSchema, validateAgainstSchema } from './schema-validator.js';
|
|
24
27
|
// Inlined to keep this directory self-contained for the standalone bun-compile
|
|
25
28
|
// build that runs as the qfg-verify pre-receive hook in app-gitea — that build
|
|
26
29
|
// only copies cli/src/verify/, so any out-of-tree imports break it. Source of
|
|
@@ -297,7 +300,7 @@ import * as path from 'node:path';
|
|
|
297
300
|
/**
|
|
298
301
|
* Validate an entire workspace directory on disk.
|
|
299
302
|
*/
|
|
300
|
-
export function validateWorkspace(workspaceDir) {
|
|
303
|
+
export function validateWorkspace(workspaceDir, options = {}) {
|
|
301
304
|
const issues = [];
|
|
302
305
|
let filesChecked = 0;
|
|
303
306
|
const stats = {
|
|
@@ -318,6 +321,7 @@ export function validateWorkspace(workspaceDir) {
|
|
|
318
321
|
const allSchemaFiles = [];
|
|
319
322
|
const segmentKeys = new Set();
|
|
320
323
|
const schemaKeys = new Set();
|
|
324
|
+
const schemaDocuments = new Map();
|
|
321
325
|
const declaredEnvIds = new Set();
|
|
322
326
|
// Check for unexpected top-level entries
|
|
323
327
|
let entries;
|
|
@@ -446,6 +450,9 @@ export function validateWorkspace(workspaceDir) {
|
|
|
446
450
|
}
|
|
447
451
|
const schemaKey = file.replace(/\.json$/, '');
|
|
448
452
|
schemaKeys.add(schemaKey);
|
|
453
|
+
schemaDocuments.set(schemaKey, result.data);
|
|
454
|
+
if (options.validateValues)
|
|
455
|
+
validateSchemaCompiles(schemaKey, result.data, relPath, issues);
|
|
449
456
|
validateKey(schemaKey, relPath, issues);
|
|
450
457
|
allSchemaFiles.push({ key: schemaKey, file: relPath });
|
|
451
458
|
stats.schemas++;
|
|
@@ -621,6 +628,8 @@ export function validateWorkspace(workspaceDir) {
|
|
|
621
628
|
});
|
|
622
629
|
}
|
|
623
630
|
}
|
|
631
|
+
if (options.validateValues)
|
|
632
|
+
validateBoundJsonValues(parsed, config, schemaDocuments, relPath, issues);
|
|
624
633
|
// Check environment IDs referenced in config exist in quonfig.json
|
|
625
634
|
if (declaredEnvIds.size > 0) {
|
|
626
635
|
for (const env of config.environments) {
|
|
@@ -666,13 +675,14 @@ function emptyStats() {
|
|
|
666
675
|
* Validate configs provided as a map of { "dir/file.json": jsonString }.
|
|
667
676
|
* Used by the pre-receive hook which reads files from git objects.
|
|
668
677
|
*/
|
|
669
|
-
export function validateFileMap(files) {
|
|
678
|
+
export function validateFileMap(files, options = {}) {
|
|
670
679
|
const issues = [];
|
|
671
680
|
let filesChecked = 0;
|
|
672
681
|
const segmentKeys = new Set();
|
|
673
682
|
const schemaKeys = new Set();
|
|
674
683
|
const allSchemaFiles = [];
|
|
675
684
|
const allConfigs = [];
|
|
685
|
+
const schemaDocuments = new Map();
|
|
676
686
|
const parsedConfigs = [];
|
|
677
687
|
const declaredEnvIds = new Set();
|
|
678
688
|
// Validate quonfig.json if it's included in the file map (it may not be in every commit)
|
|
@@ -745,6 +755,9 @@ export function validateFileMap(files) {
|
|
|
745
755
|
}
|
|
746
756
|
const schemaKey = file.replace(/\.json$/, '');
|
|
747
757
|
schemaKeys.add(schemaKey);
|
|
758
|
+
schemaDocuments.set(schemaKey, result.data);
|
|
759
|
+
if (options.validateValues)
|
|
760
|
+
validateSchemaCompiles(schemaKey, result.data, relPath, issues);
|
|
748
761
|
validateKey(schemaKey, relPath, issues);
|
|
749
762
|
allSchemaFiles.push({ key: schemaKey, file: relPath });
|
|
750
763
|
continue;
|
|
@@ -826,10 +839,10 @@ export function validateFileMap(files) {
|
|
|
826
839
|
validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues, config.variants);
|
|
827
840
|
}
|
|
828
841
|
allConfigs.push({ key: config.key, file: relPath });
|
|
829
|
-
parsedConfigs.push({
|
|
842
|
+
parsedConfigs.push({ config, dir, raw: parsed, relPath });
|
|
830
843
|
}
|
|
831
844
|
// Referential integrity
|
|
832
|
-
for (const {
|
|
845
|
+
for (const { config, raw, relPath } of parsedConfigs) {
|
|
833
846
|
const segRefs = collectSegmentReferences(config);
|
|
834
847
|
for (const ref of segRefs) {
|
|
835
848
|
if (!segmentKeys.has(ref)) {
|
|
@@ -849,6 +862,8 @@ export function validateFileMap(files) {
|
|
|
849
862
|
suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
|
|
850
863
|
});
|
|
851
864
|
}
|
|
865
|
+
if (options.validateValues)
|
|
866
|
+
validateBoundJsonValues(raw, config, schemaDocuments, relPath, issues);
|
|
852
867
|
// Check environment IDs referenced in config exist in quonfig.json
|
|
853
868
|
if (declaredEnvIds.size > 0) {
|
|
854
869
|
for (const env of config.environments) {
|
|
@@ -870,6 +885,79 @@ export function validateFileMap(files) {
|
|
|
870
885
|
return { issues, filesChecked, valid: !hasErrors, stats: emptyStats() };
|
|
871
886
|
}
|
|
872
887
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
888
|
+
/**
|
|
889
|
+
* Validate a config's schema-bound JSON values (qfg-phcv). Scope matches the
|
|
890
|
+
* app's save gate (app-quonfig config-crud-handlers.ts findJsonBoundViolations)
|
|
891
|
+
* exactly:
|
|
892
|
+
* - only `type === "config"` with `valueType === "json"` and a `schemaKey`
|
|
893
|
+
* whose schema exists (a missing schema is the reference error above);
|
|
894
|
+
* - every `{type: "json", value}` node anywhere in the RAW document: default
|
|
895
|
+
* and environment rules, weighted-value entries, top-level variants.
|
|
896
|
+
* `provided` values are not json nodes, so they are skipped;
|
|
897
|
+
* - one issue per violation, worded `Value does not match schema "<key>":
|
|
898
|
+
* <document path>.<path in value>: <message>`;
|
|
899
|
+
* - a schema that does not compile is reported once, on the schema file
|
|
900
|
+
* (validateSchemaCompiles), whether or not anything is bound to it. Values
|
|
901
|
+
* bound to it are not reported again.
|
|
902
|
+
*/
|
|
903
|
+
function validateBoundJsonValues(raw, config, schemaDocuments, file, issues) {
|
|
904
|
+
if (config.type !== 'config' || config.valueType !== 'json' || !config.schemaKey)
|
|
905
|
+
return;
|
|
906
|
+
const schemaKey = config.schemaKey;
|
|
907
|
+
const schema = schemaDocuments.get(schemaKey);
|
|
908
|
+
if (!schema)
|
|
909
|
+
return;
|
|
910
|
+
const isPlainObject = (node) => typeof node === 'object' && node !== null && !Array.isArray(node);
|
|
911
|
+
// Returns true to stop the walk.
|
|
912
|
+
const walk = (node, docPath) => {
|
|
913
|
+
if (Array.isArray(node))
|
|
914
|
+
return node.some((child, index) => walk(child, `${docPath}[${index}]`));
|
|
915
|
+
if (!isPlainObject(node))
|
|
916
|
+
return false;
|
|
917
|
+
if (node.type === 'json' && 'value' in node) {
|
|
918
|
+
const valuePath = docPath || 'value';
|
|
919
|
+
const result = validateAgainstSchema(schema, node.value);
|
|
920
|
+
if (result.ok)
|
|
921
|
+
return false;
|
|
922
|
+
if (result.kind === 'invalid-schema')
|
|
923
|
+
return true;
|
|
924
|
+
for (const violation of result.violations) {
|
|
925
|
+
issues.push({
|
|
926
|
+
file,
|
|
927
|
+
message: `Value does not match schema "${schemaKey}": ${joinSchemaPath(valuePath, violation.path)}: ${violation.message}`,
|
|
928
|
+
severity: 'error',
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
return false;
|
|
932
|
+
}
|
|
933
|
+
return Object.entries(node).some(([key, child]) => walk(child, docPath ? `${docPath}.${key}` : key));
|
|
934
|
+
};
|
|
935
|
+
walk(raw, '');
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* A schema that does not compile is an error on the schema file, bound or not.
|
|
939
|
+
* Gated on `validateValues` with the value checks, so the pre-receive hook
|
|
940
|
+
* leaves it off until phase 1b.
|
|
941
|
+
*/
|
|
942
|
+
function validateSchemaCompiles(schemaKey, schema, file, issues) {
|
|
943
|
+
const compiled = compileSchema(schema);
|
|
944
|
+
if (compiled.ok)
|
|
945
|
+
return;
|
|
946
|
+
issues.push({
|
|
947
|
+
file,
|
|
948
|
+
message: `schema ${schemaKey} is invalid: ${compiled.reason}`,
|
|
949
|
+
severity: 'error',
|
|
950
|
+
suggestion: `Fix the schema "${schemaKey}" so it compiles as JSON Schema draft 2020-12 or draft-07`,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
/** Same as app-quonfig schema-json.ts joinSchemaPath: `[n]` attaches without a dot. */
|
|
954
|
+
function joinSchemaPath(prefix, valuePath) {
|
|
955
|
+
if (!valuePath)
|
|
956
|
+
return prefix;
|
|
957
|
+
if (!prefix)
|
|
958
|
+
return valuePath;
|
|
959
|
+
return valuePath.startsWith('[') ? `${prefix}${valuePath}` : `${prefix}.${valuePath}`;
|
|
960
|
+
}
|
|
873
961
|
function validateEnvironmentIds(environments, file, issues) {
|
|
874
962
|
for (const env of environments) {
|
|
875
963
|
if (UUID_RE.test(env.id)) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: "0.
|
|
1
|
+
declare const _default: "0.2.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.2.0';
|
package/oclif.manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"packageManager": "yarn@4.11.0+sha512.4e54aeace9141df2f0177c266b05ec50dc044638157dae128c471ba65994ac802122d7ab35bcd9e81641228b7dcf24867d28e750e0bcae8a05277d600008ad54",
|
|
3
3
|
"name": "@quonfig/cli",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/quonfig/cli/issues"
|
|
7
7
|
},
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"@quonfig/node": "^1.0.0",
|
|
26
26
|
"acorn": "^8.10.0",
|
|
27
27
|
"acorn-walk": "^8.3.5",
|
|
28
|
+
"ajv": "^8.20.0",
|
|
29
|
+
"ajv-formats": "^3.0.1",
|
|
28
30
|
"chalk": "^5.6.2",
|
|
29
31
|
"common-tags": "^1.8.2",
|
|
30
32
|
"fuzzy": "^0.1.3",
|