@quonfig/cli 0.0.74 → 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.
Files changed (35) hide show
  1. package/README.md +51 -51
  2. package/dist/codegen/json-schema-to-zod.js +127 -22
  3. package/dist/codegen/language-mappers/zod-base-mapper.d.ts +11 -1
  4. package/dist/codegen/language-mappers/zod-base-mapper.js +14 -2
  5. package/dist/codegen/language-mappers/zod-to-string-mapper.d.ts +2 -1
  6. package/dist/codegen/language-mappers/zod-to-string-mapper.js +6 -2
  7. package/dist/codegen/language-mappers/zod-to-typescript-mapper.d.ts +6 -2
  8. package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +34 -5
  9. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.d.ts +6 -3
  10. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.js +22 -11
  11. package/dist/codegen/zod-introspection.d.ts +12 -0
  12. package/dist/codegen/zod-introspection.js +15 -0
  13. package/dist/commands/push.js +1 -1
  14. package/dist/commands/verify.js +1 -1
  15. package/dist/commands/workspace/bootstrap.d.ts +0 -8
  16. package/dist/commands/workspace/bootstrap.js +88 -96
  17. package/dist/migrate/push-to-cloud.js +1 -1
  18. package/dist/util/bootstrap-target.d.ts +40 -0
  19. package/dist/util/bootstrap-target.js +46 -0
  20. package/dist/util/clone-and-stack-push.d.ts +1 -4
  21. package/dist/util/clone-and-stack-push.js +2 -5
  22. package/dist/util/git-ops.d.ts +60 -2
  23. package/dist/util/git-ops.js +303 -5
  24. package/dist/verify/schema-error-reshape.d.ts +69 -0
  25. package/dist/verify/schema-error-reshape.js +257 -0
  26. package/dist/verify/schema-validator.d.ts +29 -0
  27. package/dist/verify/schema-validator.js +108 -0
  28. package/dist/verify/standalone.d.ts +31 -0
  29. package/dist/verify/standalone.js +156 -11
  30. package/dist/verify/validate.d.ts +16 -2
  31. package/dist/verify/validate.js +95 -5
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/oclif.manifest.json +2 -2
  35. 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
+ }
@@ -10,6 +10,37 @@
10
10
  * 0 All checks pass
11
11
  * 1 Validation errors found
12
12
  */
13
+ export type RefUpdate = {
14
+ newOid: string;
15
+ oldOid: string;
16
+ refName: string;
17
+ };
18
+ export type HookEnv = Record<string, string | undefined>;
19
+ export type HookOptions = {
20
+ cwd?: string;
21
+ env: HookEnv;
22
+ log: (line: string) => void;
23
+ logErr: (line: string) => void;
24
+ };
25
+ /**
26
+ * The whole pre-receive decision, as a function: returns the process exit code
27
+ * (0 accept, 1 reject). Split out of runGitHook so tests can drive it against
28
+ * real bare repos without a subprocess.
29
+ */
30
+ export declare function runHookChecks(refs: readonly RefUpdate[], opts: HookOptions): number;
31
+ /** The `cli` sha this binary was built from; `dev` for an unstamped build. */
32
+ export declare function hookVersion(env?: HookEnv): string;
33
+ /**
34
+ * `main` is append-only: every update to refs/heads/main must have a non-zero
35
+ * old oid that is an ancestor of the new oid. A delete and a create are both
36
+ * rejected (provisioning creates `main` with auto_init; no writer creates it by
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.
39
+ */
40
+ export declare function checkAppendOnlyMain(refs: readonly RefUpdate[], env: HookEnv, cwd?: string): {
41
+ bypassReason?: string;
42
+ errors: string[];
43
+ };
13
44
  /**
14
45
  * Read all JSON config files from a git commit using `git show`.
15
46
  * Works in bare repos (no working tree needed).
@@ -10,7 +10,7 @@
10
10
  * 0 All checks pass
11
11
  * 1 Validation errors found
12
12
  */
13
- import { execFileSync } from 'node:child_process';
13
+ import { execFileSync, spawnSync } from 'node:child_process';
14
14
  import * as readline from 'node:readline';
15
15
  import { formatResult, validateFileMap, validateWorkspace } from './validate.js';
16
16
  async function main() {
@@ -57,36 +57,181 @@ async function runGitHook() {
57
57
  rl.on('line', (line) => {
58
58
  const parts = line.trim().split(' ');
59
59
  if (parts.length >= 3) {
60
- refs.push({ oldOid: parts[0], newOid: parts[1], refName: parts[2] });
60
+ refs.push({ newOid: parts[1], oldOid: parts[0], refName: parts[2] });
61
61
  }
62
62
  });
63
63
  rl.on('close', resolve);
64
64
  });
65
+ process.exit(runHookChecks(refs, { env: process.env, log: console.log, logErr: console.error }));
66
+ }
67
+ /**
68
+ * The whole pre-receive decision, as a function: returns the process exit code
69
+ * (0 accept, 1 reject). Split out of runGitHook so tests can drive it against
70
+ * real bare repos without a subprocess.
71
+ */
72
+ export function runHookChecks(refs, opts) {
73
+ const { cwd, env, log, logErr } = opts;
74
+ // Always the first line, so a rollout or rollback can be confirmed by
75
+ // something other than behaviour (plan 5.6 item 5).
76
+ log(`qfg-verify ${hookVersion(env)}`);
65
77
  if (refs.length === 0) {
66
- console.log('qfg-verify: no refs received');
67
- process.exit(0);
78
+ log('qfg-verify: no refs received');
79
+ return 0;
68
80
  }
69
- // Validate the latest pushed commit for each ref
70
81
  let hasErrors = false;
82
+ // Append-only `main` (qfg-jxml.21). Runs BEFORE content validation, but does
83
+ // not short-circuit it: an operator repair is still validated for content.
84
+ const appendOnly = checkAppendOnlyMain(refs, env, cwd);
85
+ if (appendOnly.bypassReason) {
86
+ logErr(`qfg-verify: operator rewrite bypass by ${env.GITEA_PUSHER_NAME ?? '?'}: ${appendOnly.bypassReason}`);
87
+ }
88
+ for (const message of appendOnly.errors) {
89
+ logErr(message);
90
+ hasErrors = true;
91
+ }
92
+ // Content validation: the latest pushed commit for each ref.
71
93
  for (const ref of refs) {
72
94
  // Skip deletions
73
- if (ref.newOid === '0000000000000000000000000000000000000000')
95
+ if (isZeroOid(ref.newOid))
74
96
  continue;
75
- console.log(`qfg-verify: validating ${ref.refName} (${ref.newOid.slice(0, 8)})`);
97
+ log(`qfg-verify: validating ${ref.refName} (${ref.newOid.slice(0, 8)})`);
76
98
  try {
77
- const files = readFilesFromCommit(ref.newOid);
99
+ const files = readFilesFromCommit(ref.newOid, cwd);
78
100
  const result = validateFileMap(files);
79
- console.log(formatResult(result));
101
+ log(formatResult(result));
80
102
  if (!result.valid) {
81
103
  hasErrors = true;
82
104
  }
83
105
  }
84
106
  catch (error) {
85
- console.error(`qfg-verify: error reading commit ${ref.newOid}: ${error.message}`);
107
+ logErr(`qfg-verify: error reading commit ${ref.newOid}: ${error.message}`);
86
108
  hasErrors = true;
87
109
  }
88
110
  }
89
- process.exit(hasErrors ? 1 : 0);
111
+ return hasErrors ? 1 : 0;
112
+ }
113
+ function isZeroOid(oid) {
114
+ return /^0+$/.test(oid);
115
+ }
116
+ /** The `cli` sha this binary was built from; `dev` for an unstamped build. */
117
+ export function hookVersion(env = process.env) {
118
+ return env.QFG_VERIFY_SHA?.trim() || 'dev';
119
+ }
120
+ const MAIN_REF = 'refs/heads/main';
121
+ const REWRITE_OPTION = 'quonfig-rewrite=';
122
+ /**
123
+ * `main` is append-only: every update to refs/heads/main must have a non-zero
124
+ * old oid that is an ancestor of the new oid. A delete and a create are both
125
+ * rejected (provisioning creates `main` with auto_init; no writer creates it by
126
+ * push, and the zero-oid door is the only way to delete-then-recreate).
127
+ * Other refs are untouched. See plan 2026-09-17-tree-derived-cache 5.6.
128
+ */
129
+ export function checkAppendOnlyMain(refs, env, cwd) {
130
+ const mainUpdates = refs.filter((ref) => ref.refName === MAIN_REF);
131
+ if (mainUpdates.length === 0 || !ffEnforcedForRepo(env))
132
+ return { errors: [] };
133
+ const bypassReason = operatorBypassReason(env);
134
+ if (bypassReason)
135
+ return { bypassReason, errors: [] };
136
+ const errors = [];
137
+ for (const ref of mainUpdates) {
138
+ try {
139
+ if (isZeroOid(ref.newOid)) {
140
+ errors.push(rejection('deleting main is not allowed'));
141
+ }
142
+ else if (isZeroOid(ref.oldOid)) {
143
+ errors.push(rejection('creating main by push is not allowed'));
144
+ }
145
+ else if (!isAncestor(ref.oldOid, ref.newOid, cwd)) {
146
+ errors.push(rejection(`${ref.newOid.slice(0, 8)} is not a descendant of the current tip ${ref.oldOid.slice(0, 8)}`));
147
+ }
148
+ }
149
+ catch (error) {
150
+ // The ancestry check itself failed: git exited 128 (old oid missing from
151
+ // the repo), git could not be spawned, ... Fail CLOSED, but as the normal
152
+ // rejection with the recovery recipe — an escaped throw reaches the
153
+ // pusher as an opaque `qfg-verify: fatal: ...` (qfg-jxml.21 review).
154
+ errors.push(rejection(`could not check that ${ref.newOid.slice(0, 8)} descends from the current tip ${ref.oldOid.slice(0, 8)}: ${error.message}`));
155
+ }
156
+ }
157
+ return { errors };
158
+ }
159
+ /** Rollout switch: enforce only for repos named in the allowlist; `*` = all. */
160
+ function ffEnforcedForRepo(env) {
161
+ const allowlist = (env.QUONFIG_HOOK_FF_ENFORCE ?? '')
162
+ .split(',')
163
+ .map((entry) => entry.trim().toLowerCase())
164
+ .filter(Boolean);
165
+ if (allowlist.length === 0)
166
+ return false;
167
+ if (allowlist.includes('*'))
168
+ return true;
169
+ // Gitea 1.25 hands the hook its repo via these (modules/repository/env.go).
170
+ const owner = (env.GITEA_REPO_USER_NAME ?? '').trim().toLowerCase();
171
+ const name = (env.GITEA_REPO_NAME ?? '').trim().toLowerCase();
172
+ if (!owner || !name)
173
+ return false;
174
+ return allowlist.includes(`${owner}/${name}`);
175
+ }
176
+ /**
177
+ * Operator bypass, two keys, BOTH required: the pusher is the operator admin
178
+ * account (the app pushes as admin too, so identity only excludes customers)
179
+ * AND the push declares a reason with `-o quonfig-rewrite=<reason>` (which is
180
+ * what excludes accidents). No flag-file fallback.
181
+ */
182
+ function operatorBypassReason(env) {
183
+ const operator = (env.QUONFIG_HOOK_OPERATOR ?? '').trim();
184
+ const pusher = (env.GITEA_PUSHER_NAME ?? '').trim();
185
+ if (!operator || !pusher || pusher.toLowerCase() !== operator.toLowerCase())
186
+ return undefined;
187
+ const count = Number.parseInt(env.GIT_PUSH_OPTION_COUNT ?? '', 10);
188
+ if (!Number.isInteger(count))
189
+ return undefined;
190
+ for (let index = 0; index < count; index++) {
191
+ const option = env[`GIT_PUSH_OPTION_${index}`] ?? '';
192
+ if (option.startsWith(REWRITE_OPTION)) {
193
+ const reason = option.slice(REWRITE_OPTION.length).trim();
194
+ if (reason)
195
+ return reason;
196
+ }
197
+ }
198
+ return undefined;
199
+ }
200
+ /**
201
+ * `git merge-base --is-ancestor` via spawnSync, never simple-git: simple-git
202
+ * swallows a non-zero exit with no stderr, so a diverged pair would RESOLVE as
203
+ * "ancestor" and the rule would be vacuous (plan 12.2 S2). Exit 1 = not an
204
+ * ancestor; any other non-zero is an error and the push is rejected.
205
+ * The env is inherited as-is so Gitea's quarantine (GIT_QUARANTINE_PATH,
206
+ * GIT_OBJECT_DIRECTORY, GIT_ALTERNATE_OBJECT_DIRECTORIES) still resolves the
207
+ * incoming objects.
208
+ */
209
+ function isAncestor(oldOid, newOid, cwd) {
210
+ const result = spawnSync('git', ['merge-base', '--is-ancestor', oldOid, newOid], { cwd, encoding: 'utf8' });
211
+ if (result.error)
212
+ throw result.error;
213
+ if (result.status === 0)
214
+ return true;
215
+ if (result.status === 1)
216
+ return false;
217
+ throw new Error(`git merge-base --is-ancestor exited ${result.status}: ${(result.stderr || '').trim()}`);
218
+ }
219
+ function rejection(what) {
220
+ return `qfg-verify: REJECTED ${MAIN_REF}: ${what}.
221
+
222
+ main is append-only: every push must fast-forward from the current tip. What to do:
223
+
224
+ * Lost a race with another writer -- pull and rebase, then push again:
225
+ qfg pull && qfg push (or: git pull --rebase origin main && git push origin main)
226
+
227
+ * Want the content from an earlier commit -- restore it as a FORWARD commit:
228
+ git restore --source=<sha> --staged --worktree -- .
229
+ git commit -m "restore <sha>"
230
+ qfg push
231
+
232
+ * This came from \`qfg workspace bootstrap\` on an older CLI, which force-pushes --
233
+ upgrade the CLI and run it again:
234
+ npm install -g @quonfig/cli@latest`;
90
235
  }
91
236
  /**
92
237
  * Read all JSON config files from a git commit using `git show`.
@@ -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,20 +43,32 @@ 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[];
47
59
  stats: ValidationStats;
48
60
  valid: boolean;
49
61
  }
62
+ /** Every directory this validator owns: config documents plus schemas. */
63
+ export declare const KNOWN_DIRS: Set<string>;
50
64
  /**
51
65
  * Validate an entire workspace directory on disk.
52
66
  */
53
- export declare function validateWorkspace(workspaceDir: string): ValidationResult;
67
+ export declare function validateWorkspace(workspaceDir: string, options?: ValidateOptions): ValidationResult;
54
68
  /**
55
69
  * Validate configs provided as a map of { "dir/file.json": jsonString }.
56
70
  * Used by the pre-receive hook which reads files from git objects.
57
71
  */
58
- export declare function validateFileMap(files: Map<string, string>): ValidationResult;
72
+ export declare function validateFileMap(files: Map<string, string>, options?: ValidateOptions): ValidationResult;
59
73
  export declare function validateKey(key: string, file: string, issues: ValidationIssue[]): void;
60
74
  export declare function formatResult(result: ValidationResult): string;