test(cli): guard settings reference against schema drift (#16085)

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-11-02 15:29:30 +08:00
1 parent 0ebbe15221
commit 73a7268447
2 files changed
+262 -2

No files matched your search

+2 -2
View File
@@ -439,7 +439,7 @@ The bridge-availability and missing-bridge warning rules below describe direct t
| -------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `memory.enableManagedAutoMemory` | boolean | Enable background extraction of memories from conversations. | `true` |
| `memory.enableManagedAutoDream` | boolean | Enable automatic consolidation (deduplication and cleanup) of collected memories. | `true` |
| `memory.enableAutoSkill` | boolean | Enable background review for reusable project skills after tool-heavy sessions. | `true` |
| `memory.enableAutoSkill` | boolean | Enable background review for reusable project skills after tool-heavy sessions. | `false` |
| `memory.autoSkillConfirm` | boolean | Ask for confirmation before auto-generated skills are added to the skill library. When off, auto-skills are saved immediately. | `true` |
| `memory.enableTeamMemory` | boolean | Enable a project memory tier shared with collaborators via the git-tracked `.qwen/team-memory/` directory. Writes to it are secret-scanned and reviewable in the git diff. | `false` |
| `memory.enableTeamMemorySync` | boolean | When team memory is enabled, automatically commit, fast-forward-pull, and push the `.qwen/team-memory/` directory at session start so collaborators stay in sync. Requires a configured git upstream. | `false` |
@@ -648,7 +648,7 @@ LSP server configuration is done through `.lsp.json` files in your project root
| `security.folderTrust.enabled` | boolean | Setting to track whether Folder trust is enabled. | `false` |
| `security.auth.selectedType` | string | The currently selected authentication type. | `undefined` |
| `security.auth.enforcedType` | string | The required auth type (useful for enterprises). | `undefined` |
| `security.auth.useExternal` | boolean | Whether to use an external authentication flow. | `undefined` |
| `security.auth.useExternal` | boolean | Whether to use an external authentication flow. | `false` |
| `security.auth.apiKey` | string | **Deprecated.** API key for OpenAI-compatible authentication. Migrate to `modelProviders` with `envKey` instead — see [Model Providers](./model-providers). | `undefined` |
| `security.auth.baseUrl` | string | **Deprecated.** Base URL for the OpenAI-compatible API. Migrate to `modelProviders` instead — see [Model Providers](./model-providers). | `undefined` |
| `security.allowedInsecureVoiceBaseUrls` | array of strings | Complete voice provider base URLs that may use HTTP or resolve to private-network addresses. Each entry must include an explicit `http://` or `https://` scheme and the full path (for example, `/v1`); only URL serialization and trailing slashes are normalized. Wildcards are not supported; metadata, link-local, local-use NAT64, 6to4, and Teredo addresses remain blocked even when listed, as do hostnames that resolve to loopback; IPv4-mapped, IPv4-compatible, and well-known NAT64 (`64:ff9b::/96`) literals are classified by their embedded IPv4 address. Only User, System, and SystemDefaults scopes are honored. Use only for trusted endpoints in managed private networks. Cleartext HTTP also exposes the provider API key transmitted in the Authorization header. An allowlisted hostname is only as trustworthy as its DNS; prefer IP-literal entries when the gateway address is stable. The exact match covers the batch request URL; streaming transports connect to a WebSocket URL derived from it (same scheme, host, and port, `/api-ws/v1/...` path), not to the allowlisted path itself. | `[]` |
@@ -0,0 +1,260 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { getSettingsSchema } from './settingsSchema.js';
type SchemaType = 'boolean' | 'string' | 'number' | 'array' | 'object';
interface SchemaNode {
type?: string;
default?: unknown;
enum?: readonly unknown[];
properties?: Record<string, SchemaNode>;
anyOf?: readonly SchemaNode[];
oneOf?: readonly SchemaNode[];
jsonSchemaOverride?: SchemaNode;
}
interface DocumentedSetting {
key: string;
line: number;
type: string;
default: string;
}
const repoRoot = join(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'..',
'..',
);
function splitMarkdownRow(line: string): string[] {
const body = line.trim().replace(/^\|/, '').replace(/\|$/, '');
const cells: string[] = [];
let cell = '';
let escaped = false;
for (const char of body) {
if (escaped) {
cell += char;
escaped = false;
} else if (char === '\\') {
cell += char;
escaped = true;
} else if (char === '|') {
cells.push(cell.trim());
cell = '';
} else {
cell += char;
}
}
cells.push(cell.trim());
return cells.map((value) => value.replace(/\\\|/g, '|'));
}
function flattenSchema(
schema: Record<string, SchemaNode>,
prefix = '',
result = new Map<string, SchemaNode>(),
): Map<string, SchemaNode> {
for (const [key, value] of Object.entries(schema)) {
const node = value as SchemaNode;
const path = prefix ? `${prefix}.${key}` : key;
result.set(path, node);
const nestedProperties =
node.properties ?? node.jsonSchemaOverride?.properties;
if (nestedProperties) {
flattenSchema(nestedProperties, path, result);
}
}
return result;
}
function schemaTypes(node: SchemaNode): Set<SchemaType> {
const overrideTypes = node.jsonSchemaOverride
? schemaTypes(node.jsonSchemaOverride)
: new Set<SchemaType>();
if (overrideTypes.size > 0) {
return overrideTypes;
}
for (const union of [node.anyOf, node.oneOf]) {
if (union) {
return new Set(union.flatMap((member) => [...schemaTypes(member)]));
}
}
if (node.enum) {
return node.enum.every((value) => typeof value === 'string')
? new Set<SchemaType>(['string'])
: new Set<SchemaType>();
}
switch (node.type) {
case 'boolean':
case 'string':
case 'number':
case 'object':
return new Set([node.type]);
case 'integer':
return new Set(['number']);
case 'array':
return new Set(['array']);
case 'enum':
return new Set(['string']);
default:
return new Set();
}
}
function documentedTypes(value: string): Set<SchemaType> {
const normalized = value
.replace(/`/g, '')
.replace(/\\\|/g, '|')
.toLowerCase();
const types = new Set<SchemaType>();
for (const part of normalized.split(/\s*\|\s*|\s+or\s+/)) {
if (/\bboolean\b/.test(part)) {
types.add('boolean');
}
if (/(array of strings|string\[\]|array)/.test(part)) {
types.add('array');
} else if (/\b(string|enum)\b/.test(part)) {
types.add('string');
}
if (/\bobject\b/.test(part)) {
types.add('object');
}
if (/\b(integer|number)\b/.test(part)) {
types.add('number');
}
}
return types;
}
function booleanDefault(value: string): boolean | undefined | null {
const normalized = value.replace(/`/g, '').trim();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
if (normalized === 'undefined') return undefined;
return null;
}
function documentedSettings(): Map<string, DocumentedSetting> {
const markdown = readFileSync(
join(repoRoot, 'docs', 'users', 'configuration', 'settings.md'),
'utf8',
);
const sectionStart = markdown.indexOf('### Available settings');
const sectionEnd = markdown.indexOf(
'### Example `settings.json`',
sectionStart,
);
if (sectionStart < 0 || sectionEnd < 0) {
throw new Error('Could not locate the settings reference tables');
}
const schema = flattenSchema(
getSettingsSchema() as unknown as Record<string, SchemaNode>,
);
const result = new Map<string, DocumentedSetting>();
const section = markdown.slice(sectionStart, sectionEnd);
let inSettingsTable = false;
section.split('\n').forEach((line, index) => {
if (!line.startsWith('|')) {
inSettingsTable = false;
return;
}
const cells = splitMarkdownRow(line);
if (/^(Setting|Property)$/.test(cells[0] ?? '') && cells[1] === 'Type') {
inSettingsTable = true;
return;
}
if (!inSettingsTable) return;
const keyMatch = /^`([^`]+)`$/.exec(cells[0] ?? '');
const key = keyMatch?.[1];
if (!key || !schema.has(key)) return;
result.set(key, {
key,
line: markdown.slice(0, sectionStart).split('\n').length + index,
type: cells[1] ?? '',
default: cells[3] ?? '',
});
});
return result;
}
describe('settings reference schema sync', () => {
it('keeps documented types and boolean defaults aligned', () => {
const schema = flattenSchema(
getSettingsSchema() as unknown as Record<string, SchemaNode>,
);
const documented = documentedSettings();
const mismatches: string[] = [];
for (const [key, row] of documented) {
const node = schema.get(key);
if (!node) continue;
const expectedTypes = schemaTypes(node);
const actualTypes = documentedTypes(row.type);
if (
expectedTypes.size === 0 ||
actualTypes.size === 0 ||
[...actualTypes].some((type) => !expectedTypes.has(type)) ||
[...expectedTypes].some((type) => !actualTypes.has(type))
) {
mismatches.push(
`${row.line}: ${key} documents ${
row.type || '(missing type)'
}; schema has ${[...expectedTypes].join(' or ')}`,
);
continue;
}
if (expectedTypes.size === 1 && expectedTypes.has('boolean')) {
const expectedDefault = booleanDefault(row.default);
if (expectedDefault !== null && node.default !== expectedDefault) {
mismatches.push(
`${row.line}: ${key} documents default ${
row.default || '(missing)'
}; schema default is ${String(node.default)}`,
);
}
}
}
expect(mismatches).toEqual([]);
});
it('keeps general.batchAutoCollect explicitly documented', () => {
const documented = documentedSettings().get('general.batchAutoCollect');
const schema = getSettingsSchema().general.properties.batchAutoCollect;
expect(documented).toMatchObject({
type: 'boolean',
default: '`true`',
});
expect(schema).toMatchObject({
type: 'boolean',
default: true,
});
});
});