@arcadialdev/arcality 4.1.0 → 4.1.1
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/.agents/skills/db-validation-evidence/SKILL.md +43 -0
- package/.agents/skills/db-validation-evidence/agents/openai.yaml +4 -0
- package/.agents/skills/db-validation-evidence/references/mission-patterns.md +130 -0
- package/.agents/skills/evidence-synthesis-reporting/SKILL.md +31 -0
- package/.agents/skills/form-expert/SKILL.md +98 -0
- package/.agents/skills/investigation-protocol/SKILL.md +56 -0
- package/.agents/skills/mission-generation-reviewer/SKILL.md +25 -0
- package/.agents/skills/modal-master/SKILL.md +46 -0
- package/.agents/skills/native-control-expert/SKILL.md +74 -0
- package/.agents/skills/qa-context-governance/SKILL.md +23 -0
- package/.agents/skills/security-evidence-triage/SKILL.md +23 -0
- package/.agents/skills/test-data-lifecycle/SKILL.md +24 -0
- package/README.md +103 -163
- package/bin/arcality.mjs +25 -25
- package/package.json +75 -75
- package/scripts/edit-config.mjs +843 -0
- package/scripts/gen-and-run.mjs +237 -169
- package/scripts/generate.mjs +236 -236
- package/scripts/init.mjs +47 -47
- package/src/configManager.mjs +13 -13
- package/src/envSetup.ts +229 -205
- package/src/services/codebaseAnalyzer.mjs +59 -59
- package/src/services/databaseValidationService.mjs +598 -0
- package/src/services/executionEvidenceService.mjs +124 -0
- package/src/services/generatedMissionSchema.mjs +76 -76
- package/src/services/generatedMissionStore.mjs +117 -117
- package/src/services/generationContext.mjs +242 -242
- package/src/services/mcpStdioClient.mjs +204 -0
- package/src/services/missionGenerator.mjs +329 -329
- package/src/services/routeDiscovery.mjs +762 -762
- package/tests/_helpers/ArcalityReporter.js +1342 -1255
- package/tests/_helpers/agentic-runner.bundle.spec.js +1354 -243
- package/.agent/skills/form-expert.md +0 -102
- package/.agent/skills/investigation-protocol.md +0 -61
- package/.agent/skills/modal-master.md +0 -41
- package/.agent/skills/native-control-expert.md +0 -82
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createRequire } from 'module';
|
|
4
|
+
import { load as loadYaml } from 'js-yaml';
|
|
5
|
+
import { callMcpToolWithConfig, extractRowsFromMcpToolResult } from './mcpStdioClient.mjs';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 8000;
|
|
8
|
+
const DEFAULT_SAMPLE_LIMIT = 3;
|
|
9
|
+
const DEFAULT_CATALOG_PATH = path.join('.arcality', 'db-validations.yaml');
|
|
10
|
+
const DEFAULT_MCP_CONFIG_PATH = path.join('.arcality', 'mcp.postgres.json');
|
|
11
|
+
const SENSITIVE_FIELD_PATTERN = /(password|passwd|pwd|secret|token|api[_-]?key|authorization|auth|credential|session|cookie)/i;
|
|
12
|
+
const SQL_WRITE_PATTERN = /\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|vacuum|copy|call|execute|merge)\b/i;
|
|
13
|
+
|
|
14
|
+
function toEnvKey(value) {
|
|
15
|
+
return String(value || '')
|
|
16
|
+
.trim()
|
|
17
|
+
.toUpperCase()
|
|
18
|
+
.replace(/[^A-Z0-9]+/g, '_')
|
|
19
|
+
.replace(/^_+|_+$/g, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isEnabled(value) {
|
|
23
|
+
return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isPlainObject(value) {
|
|
27
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeDbValue(value) {
|
|
31
|
+
if (value instanceof Date) return value.toISOString();
|
|
32
|
+
if (typeof value === 'bigint') return value.toString();
|
|
33
|
+
if (Buffer.isBuffer(value)) return '[binary]';
|
|
34
|
+
if (Array.isArray(value)) return value.map(normalizeDbValue);
|
|
35
|
+
if (isPlainObject(value)) {
|
|
36
|
+
return Object.fromEntries(
|
|
37
|
+
Object.entries(value).map(([key, entryValue]) => [key, normalizeDbValue(entryValue)])
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeRows(rows) {
|
|
44
|
+
if (!Array.isArray(rows)) return [];
|
|
45
|
+
return rows.map(row => normalizeDbValue(row));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeRedactSet(...sources) {
|
|
49
|
+
const redact = new Set();
|
|
50
|
+
for (const source of sources) {
|
|
51
|
+
if (Array.isArray(source)) {
|
|
52
|
+
for (const field of source) {
|
|
53
|
+
const name = String(field || '').trim();
|
|
54
|
+
if (name) redact.add(name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return redact;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function shouldRedactField(field, redactSet) {
|
|
62
|
+
return redactSet.has(field) || SENSITIVE_FIELD_PATTERN.test(field);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function redactValue(value) {
|
|
66
|
+
if (value === undefined || value === null) return value;
|
|
67
|
+
return '[REDACTED]';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function redactObject(value, redactSet) {
|
|
71
|
+
if (!isPlainObject(value)) return value;
|
|
72
|
+
return Object.fromEntries(
|
|
73
|
+
Object.entries(value).map(([key, entryValue]) => [
|
|
74
|
+
key,
|
|
75
|
+
shouldRedactField(key, redactSet) ? redactValue(entryValue) : entryValue
|
|
76
|
+
])
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function redactRows(rows, redactSet, limit = DEFAULT_SAMPLE_LIMIT) {
|
|
81
|
+
return normalizeRows(rows)
|
|
82
|
+
.slice(0, limit)
|
|
83
|
+
.map(row => redactObject(row, redactSet));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validateReadOnlySql(sql) {
|
|
87
|
+
const text = String(sql || '').trim();
|
|
88
|
+
if (!text) {
|
|
89
|
+
throw new Error('Database validation query is empty.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const withoutTrailingSemicolon = text.replace(/;\s*$/, '');
|
|
93
|
+
if (withoutTrailingSemicolon.includes(';')) {
|
|
94
|
+
throw new Error('Database validation queries must contain a single statement.');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!/^(select|with)\b/i.test(withoutTrailingSemicolon)) {
|
|
98
|
+
throw new Error('Database validation queries must start with SELECT or WITH.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (SQL_WRITE_PATTERN.test(withoutTrailingSemicolon)) {
|
|
102
|
+
throw new Error('Database validation queries must be read-only.');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveConfigPath(configPath, fallbackPath, options = {}) {
|
|
107
|
+
const projectRoot = options.projectRoot || process.env.ARCALITY_PROJECT_ROOT || process.cwd();
|
|
108
|
+
return path.isAbsolute(configPath)
|
|
109
|
+
? configPath
|
|
110
|
+
: path.join(projectRoot, configPath || fallbackPath);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readJsonOrYamlFile(filePath) {
|
|
114
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
115
|
+
if (/\.json$/i.test(filePath)) {
|
|
116
|
+
return JSON.parse(raw);
|
|
117
|
+
}
|
|
118
|
+
return loadYaml(raw);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function resolveCatalogPath(options = {}) {
|
|
122
|
+
const configuredPath = options.catalogPath ||
|
|
123
|
+
process.env.ARCALITY_DB_CATALOG ||
|
|
124
|
+
process.env.ARCALITY_DB_VALIDATIONS_FILE ||
|
|
125
|
+
DEFAULT_CATALOG_PATH;
|
|
126
|
+
return resolveConfigPath(configuredPath, DEFAULT_CATALOG_PATH, options);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function resolveMcpConfigPath(options = {}) {
|
|
130
|
+
const configuredPath = options.mcpConfigPath ||
|
|
131
|
+
process.env.ARCALITY_DB_MCP_CONFIG ||
|
|
132
|
+
DEFAULT_MCP_CONFIG_PATH;
|
|
133
|
+
return resolveConfigPath(configuredPath, DEFAULT_MCP_CONFIG_PATH, options);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function loadDatabaseValidationCatalog(options = {}) {
|
|
137
|
+
const catalogPath = resolveCatalogPath(options);
|
|
138
|
+
if (!fs.existsSync(catalogPath)) {
|
|
139
|
+
throw new Error(`Database validation catalog not found: ${catalogPath}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const raw = readJsonOrYamlFile(catalogPath);
|
|
143
|
+
const catalog = raw && typeof raw === 'object' ? raw : {};
|
|
144
|
+
const queries = isPlainObject(catalog.queries) ? catalog.queries : {};
|
|
145
|
+
return { catalogPath, queries };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function loadDatabaseMcpConfig(options = {}) {
|
|
149
|
+
const configPath = resolveMcpConfigPath(options);
|
|
150
|
+
if (!fs.existsSync(configPath)) {
|
|
151
|
+
throw new Error(`MCP database config not found: ${configPath}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const raw = readJsonOrYamlFile(configPath);
|
|
155
|
+
if (!isPlainObject(raw)) {
|
|
156
|
+
throw new Error(`Invalid MCP database config: ${configPath}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { configPath, config: raw };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function resolveDatabaseProvider(options = {}) {
|
|
163
|
+
const rawProvider = String(options.provider || process.env.ARCALITY_DB_PROVIDER || 'postgres').trim().toLowerCase();
|
|
164
|
+
if (['mcp', 'mcp_postgres', 'mcp-postgres'].includes(rawProvider)) {
|
|
165
|
+
return 'mcp';
|
|
166
|
+
}
|
|
167
|
+
return 'postgres';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function resolveDatabaseConnectionEnv(options = {}) {
|
|
171
|
+
const profile = String(options.profile || process.env.ARCALITY_DB_PROFILE || 'qa_local').trim();
|
|
172
|
+
const profileKey = toEnvKey(profile);
|
|
173
|
+
const profileEnvName = profileKey ? `ARCALITY_DB_${profileKey}_URL` : '';
|
|
174
|
+
const connectionEnvName = options.connectionEnv ||
|
|
175
|
+
process.env.ARCALITY_DB_CONNECTION_ENV ||
|
|
176
|
+
profileEnvName ||
|
|
177
|
+
'ARCALITY_DB_CONNECTION';
|
|
178
|
+
const connectionString = process.env[connectionEnvName] ||
|
|
179
|
+
process.env.ARCALITY_DB_CONNECTION ||
|
|
180
|
+
process.env.ARCALITY_QA_POSTGRES_URL ||
|
|
181
|
+
'';
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
profile,
|
|
185
|
+
connectionEnvName,
|
|
186
|
+
hasConnectionString: !!connectionString,
|
|
187
|
+
connectionString
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeTimeoutMs(value) {
|
|
192
|
+
const parsed = Number(value);
|
|
193
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function resolveProfileRegistryPath(options = {}) {
|
|
197
|
+
const configuredPath = options.registryPath || options.registry_path || path.join('.arcality', 'db-profiles.json');
|
|
198
|
+
return resolveConfigPath(configuredPath, path.join('.arcality', 'db-profiles.json'), options);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function registerDatabaseProfile(profileInput = {}, options = {}) {
|
|
202
|
+
const profile = String(profileInput.profile || options.profile || process.env.ARCALITY_DB_PROFILE || 'qa_local').trim() || 'qa_local';
|
|
203
|
+
const provider = resolveDatabaseProvider({ provider: profileInput.provider || options.provider || process.env.ARCALITY_DB_PROVIDER });
|
|
204
|
+
const connection = resolveDatabaseConnectionEnv({
|
|
205
|
+
profile,
|
|
206
|
+
connectionEnv: profileInput.connection_env || profileInput.connectionEnv || options.connectionEnv
|
|
207
|
+
});
|
|
208
|
+
const catalogPath = resolveCatalogPath({
|
|
209
|
+
...options,
|
|
210
|
+
catalogPath: profileInput.catalog_path || profileInput.catalogPath
|
|
211
|
+
});
|
|
212
|
+
const timeoutMs = normalizeTimeoutMs(profileInput.timeout_ms || profileInput.timeoutMs || options.timeoutMs || process.env.ARCALITY_DB_TIMEOUT_MS);
|
|
213
|
+
const warnings = [];
|
|
214
|
+
const result = {
|
|
215
|
+
type: 'database_profile_registration',
|
|
216
|
+
profile,
|
|
217
|
+
provider,
|
|
218
|
+
connection_env: connection.connectionEnvName,
|
|
219
|
+
has_connection_string: connection.hasConnectionString,
|
|
220
|
+
catalog_path: catalogPath,
|
|
221
|
+
catalog_exists: fs.existsSync(catalogPath),
|
|
222
|
+
timeout_ms: timeoutMs,
|
|
223
|
+
warnings
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (!result.has_connection_string) {
|
|
227
|
+
warnings.push('Missing PostgreSQL connection string in ' + connection.connectionEnvName + '.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!result.catalog_exists) {
|
|
231
|
+
warnings.push('Database validation catalog not found: ' + catalogPath);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (profileInput.validate_catalog !== false && result.catalog_exists) {
|
|
235
|
+
try {
|
|
236
|
+
const catalog = loadDatabaseValidationCatalog({ ...options, catalogPath });
|
|
237
|
+
result.query_count = Object.keys(catalog.queries || {}).length;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
result.query_count = 0;
|
|
240
|
+
warnings.push(error instanceof Error ? error.message : String(error));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (provider === 'mcp') {
|
|
245
|
+
const mcpConfigPath = resolveMcpConfigPath({
|
|
246
|
+
...options,
|
|
247
|
+
mcpConfigPath: profileInput.mcp_config_path || profileInput.mcpConfigPath
|
|
248
|
+
});
|
|
249
|
+
result.mcp_config_path = mcpConfigPath;
|
|
250
|
+
result.mcp_config_exists = fs.existsSync(mcpConfigPath);
|
|
251
|
+
if (!result.mcp_config_exists) {
|
|
252
|
+
warnings.push('MCP database config not found: ' + mcpConfigPath);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (profileInput.persist === true) {
|
|
257
|
+
const registryPath = resolveProfileRegistryPath({
|
|
258
|
+
...options,
|
|
259
|
+
registryPath: profileInput.registry_path || profileInput.registryPath
|
|
260
|
+
});
|
|
261
|
+
let registry = { profiles: {} };
|
|
262
|
+
if (fs.existsSync(registryPath)) {
|
|
263
|
+
try {
|
|
264
|
+
const existing = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
265
|
+
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
|
|
266
|
+
registry = existing;
|
|
267
|
+
registry.profiles = registry.profiles && typeof registry.profiles === 'object' ? registry.profiles : {};
|
|
268
|
+
}
|
|
269
|
+
} catch {}
|
|
270
|
+
}
|
|
271
|
+
registry.profiles[profile] = {
|
|
272
|
+
provider,
|
|
273
|
+
connection_env: connection.connectionEnvName,
|
|
274
|
+
catalog_path: catalogPath,
|
|
275
|
+
timeout_ms: timeoutMs,
|
|
276
|
+
...(result.mcp_config_path ? { mcp_config_path: result.mcp_config_path } : {})
|
|
277
|
+
};
|
|
278
|
+
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
|
|
279
|
+
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf8');
|
|
280
|
+
result.persisted = true;
|
|
281
|
+
result.registry_path = registryPath;
|
|
282
|
+
} else {
|
|
283
|
+
result.persisted = false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
result.status = warnings.length === 0 ? 'ready' : 'warning';
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function loadPgPool() {
|
|
291
|
+
const roots = [process.env.ARCALITY_ROOT, process.cwd()].filter(Boolean);
|
|
292
|
+
for (const root of roots) {
|
|
293
|
+
try {
|
|
294
|
+
const requireFromRoot = createRequire(path.join(root, 'package.json'));
|
|
295
|
+
const pg = requireFromRoot('pg');
|
|
296
|
+
return pg.Pool;
|
|
297
|
+
} catch { }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
throw new Error('PostgreSQL driver "pg" is not installed. Run `npm install pg --save` in this project to enable database validation.');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function getQueryEntry(catalog, queryId) {
|
|
304
|
+
const entry = catalog.queries[queryId];
|
|
305
|
+
if (!isPlainObject(entry)) {
|
|
306
|
+
throw new Error(`Database validation query_id not found in catalog: ${queryId}`);
|
|
307
|
+
}
|
|
308
|
+
validateReadOnlySql(entry.sql);
|
|
309
|
+
return entry;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function buildQueryValues(entry, params) {
|
|
313
|
+
const paramNames = Array.isArray(entry.params) ? entry.params : [];
|
|
314
|
+
return paramNames.map(name => {
|
|
315
|
+
const key = String(name);
|
|
316
|
+
if (!Object.prototype.hasOwnProperty.call(params || {}, key)) {
|
|
317
|
+
throw new Error(`Missing database validation param: ${key}`);
|
|
318
|
+
}
|
|
319
|
+
return params[key];
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function evaluateDatabaseValidationRows(validation, rows, queryEntry = {}) {
|
|
324
|
+
if (!isPlainObject(validation)) {
|
|
325
|
+
throw new Error('Database validation must be an object.');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const normalizedRows = normalizeRows(rows);
|
|
329
|
+
const expectBlock = isPlainObject(validation.expect) ? validation.expect : {};
|
|
330
|
+
const expectsFields = isPlainObject(expectBlock.fields);
|
|
331
|
+
const expectedExists = typeof expectBlock.exists === 'boolean'
|
|
332
|
+
? expectBlock.exists
|
|
333
|
+
: (expectsFields ? true : undefined);
|
|
334
|
+
const redactSet = normalizeRedactSet(queryEntry.redact, validation.redact);
|
|
335
|
+
const checks = [];
|
|
336
|
+
|
|
337
|
+
if (typeof expectedExists === 'boolean') {
|
|
338
|
+
const actualExists = normalizedRows.length > 0;
|
|
339
|
+
checks.push({
|
|
340
|
+
type: 'exists',
|
|
341
|
+
expected: expectedExists,
|
|
342
|
+
actual: actualExists,
|
|
343
|
+
passed: actualExists === expectedExists
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (expectsFields) {
|
|
348
|
+
const firstRow = normalizedRows[0] || {};
|
|
349
|
+
for (const [field, expected] of Object.entries(expectBlock.fields)) {
|
|
350
|
+
const hasField = Object.prototype.hasOwnProperty.call(firstRow, field);
|
|
351
|
+
const actual = hasField ? firstRow[field] : undefined;
|
|
352
|
+
const passed = hasField && actual === normalizeDbValue(expected);
|
|
353
|
+
const redacted = shouldRedactField(field, redactSet);
|
|
354
|
+
checks.push({
|
|
355
|
+
type: 'field_exact',
|
|
356
|
+
field,
|
|
357
|
+
expected: redacted ? redactValue(expected) : normalizeDbValue(expected),
|
|
358
|
+
actual: redacted ? redactValue(actual) : actual,
|
|
359
|
+
passed
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const passed = checks.length > 0 && checks.every(check => check.passed);
|
|
365
|
+
return {
|
|
366
|
+
name: String(validation.name || validation.query_id || validation.queryId || 'database_validation'),
|
|
367
|
+
query_id: String(validation.query_id || validation.queryId || ''),
|
|
368
|
+
status: passed ? 'passed' : 'failed',
|
|
369
|
+
passed,
|
|
370
|
+
row_count: normalizedRows.length,
|
|
371
|
+
checks,
|
|
372
|
+
rows_sample: redactRows(normalizedRows, redactSet)
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function queryWithTimeout(pool, query, timeoutMs) {
|
|
377
|
+
let timeoutHandle;
|
|
378
|
+
const timeout = new Promise((_, reject) => {
|
|
379
|
+
timeoutHandle = setTimeout(() => {
|
|
380
|
+
reject(new Error(`Database validation query timed out after ${timeoutMs}ms.`));
|
|
381
|
+
}, timeoutMs);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
return await Promise.race([pool.query(query), timeout]);
|
|
386
|
+
} finally {
|
|
387
|
+
clearTimeout(timeoutHandle);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function queryDatabaseRowsViaPostgres({ validation, queryEntry, timeoutMs, startedAt, options = {} }) {
|
|
392
|
+
const connection = resolveDatabaseConnectionEnv({ profile: validation.profile || options.profile });
|
|
393
|
+
if (!connection.hasConnectionString) {
|
|
394
|
+
throw new Error(`Missing PostgreSQL connection string in ${connection.connectionEnvName}.`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const Pool = loadPgPool();
|
|
398
|
+
const values = buildQueryValues(queryEntry, validation.params || {});
|
|
399
|
+
const pool = new Pool({
|
|
400
|
+
connectionString: connection.connectionString,
|
|
401
|
+
max: 1,
|
|
402
|
+
idleTimeoutMillis: 1000,
|
|
403
|
+
connectionTimeoutMillis: Math.min(timeoutMs, 10000),
|
|
404
|
+
statement_timeout: timeoutMs
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
const result = await queryWithTimeout(pool, { text: queryEntry.sql, values }, timeoutMs);
|
|
409
|
+
return {
|
|
410
|
+
rows: result.rows || [],
|
|
411
|
+
metadata: {
|
|
412
|
+
provider: 'postgres',
|
|
413
|
+
profile: connection.profile,
|
|
414
|
+
catalog_path: options.catalogPath || resolveCatalogPath(options),
|
|
415
|
+
duration_ms: Date.now() - startedAt,
|
|
416
|
+
executed_at: new Date().toISOString()
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
} finally {
|
|
420
|
+
await pool.end().catch(() => {});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function executeDatabaseValidationViaPostgres({ validation, queryEntry, timeoutMs, startedAt, options = {} }) {
|
|
425
|
+
const { rows, metadata } = await queryDatabaseRowsViaPostgres({ validation, queryEntry, timeoutMs, startedAt, options });
|
|
426
|
+
const evaluated = evaluateDatabaseValidationRows(validation, rows, queryEntry);
|
|
427
|
+
return {
|
|
428
|
+
...evaluated,
|
|
429
|
+
...metadata
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function queryDatabaseRowsViaMcp({ validation, queryEntry, timeoutMs, startedAt, options = {} }) {
|
|
434
|
+
const { configPath, config } = loadDatabaseMcpConfig(options);
|
|
435
|
+
const values = buildQueryValues(queryEntry, validation.params || {});
|
|
436
|
+
const toolContext = {
|
|
437
|
+
sql: queryEntry.sql,
|
|
438
|
+
params: values,
|
|
439
|
+
paramsNamed: validation.params || {},
|
|
440
|
+
paramsJson: JSON.stringify(values),
|
|
441
|
+
queryId: validation.query_id || validation.queryId || '',
|
|
442
|
+
validationName: validation.name || validation.query_id || validation.queryId || 'database_validation',
|
|
443
|
+
timeoutMs
|
|
444
|
+
};
|
|
445
|
+
const result = await callMcpToolWithConfig(config, toolContext);
|
|
446
|
+
const rows = extractRowsFromMcpToolResult(result, String(config.resultRowsPath || '').trim());
|
|
447
|
+
return {
|
|
448
|
+
rows,
|
|
449
|
+
metadata: {
|
|
450
|
+
provider: 'mcp',
|
|
451
|
+
mcp_config_path: configPath,
|
|
452
|
+
mcp_tool_name: String(config.toolName || 'query'),
|
|
453
|
+
catalog_path: options.catalogPath || resolveCatalogPath(options),
|
|
454
|
+
duration_ms: Date.now() - startedAt,
|
|
455
|
+
executed_at: new Date().toISOString()
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function executeDatabaseValidationViaMcp({ validation, queryEntry, timeoutMs, startedAt, options = {} }) {
|
|
461
|
+
const { rows, metadata } = await queryDatabaseRowsViaMcp({ validation, queryEntry, timeoutMs, startedAt, options });
|
|
462
|
+
const evaluated = evaluateDatabaseValidationRows(validation, rows, queryEntry);
|
|
463
|
+
return {
|
|
464
|
+
...evaluated,
|
|
465
|
+
...metadata
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function executeDatabaseValidation(validation, options = {}) {
|
|
470
|
+
if (!isEnabled(process.env.ARCALITY_DB_ENABLED) && !options.allowDisabled) {
|
|
471
|
+
throw new Error('Database validation is disabled. Set ARCALITY_DB_ENABLED=true to use it.');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const queryId = String(validation?.query_id || validation?.queryId || '').trim();
|
|
475
|
+
if (!queryId) {
|
|
476
|
+
throw new Error('Database validation requires query_id.');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const startedAt = Date.now();
|
|
480
|
+
const catalog = loadDatabaseValidationCatalog(options);
|
|
481
|
+
const queryEntry = getQueryEntry(catalog, queryId);
|
|
482
|
+
const timeoutMs = Number(validation.timeout_ms || validation.timeoutMs || process.env.ARCALITY_DB_TIMEOUT_MS || DEFAULT_TIMEOUT_MS);
|
|
483
|
+
const provider = resolveDatabaseProvider(options);
|
|
484
|
+
|
|
485
|
+
if (provider === 'mcp') {
|
|
486
|
+
return executeDatabaseValidationViaMcp({ validation, queryEntry, timeoutMs, startedAt, options: { ...options, catalogPath: catalog.catalogPath } });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return executeDatabaseValidationViaPostgres({ validation, queryEntry, timeoutMs, startedAt, options: { ...options, catalogPath: catalog.catalogPath } });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function buildDatabaseValidationPreview(validation, rows, queryEntry = {}, metadata = {}) {
|
|
493
|
+
const redactSet = normalizeRedactSet(queryEntry.redact, validation?.redact);
|
|
494
|
+
const normalizedRows = normalizeRows(rows);
|
|
495
|
+
return {
|
|
496
|
+
type: 'database_validation_preview',
|
|
497
|
+
name: String(validation?.name || validation?.query_id || validation?.queryId || 'database_validation_preview'),
|
|
498
|
+
query_id: String(validation?.query_id || validation?.queryId || ''),
|
|
499
|
+
params: redactObject(normalizeDbValue(validation?.params || {}), redactSet),
|
|
500
|
+
row_count: normalizedRows.length,
|
|
501
|
+
rows_sample: redactRows(normalizedRows, redactSet),
|
|
502
|
+
query: {
|
|
503
|
+
description: String(queryEntry.description || ''),
|
|
504
|
+
params: Array.isArray(queryEntry.params) ? queryEntry.params.map(name => String(name)) : []
|
|
505
|
+
},
|
|
506
|
+
supports_expect: {
|
|
507
|
+
exists: true,
|
|
508
|
+
fields: true
|
|
509
|
+
},
|
|
510
|
+
...metadata
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function previewDatabaseValidation(validation, options = {}) {
|
|
515
|
+
if (!isEnabled(process.env.ARCALITY_DB_ENABLED) && !options.allowDisabled) {
|
|
516
|
+
throw new Error('Database validation is disabled. Set ARCALITY_DB_ENABLED=true to use it.');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const queryId = String(validation?.query_id || validation?.queryId || '').trim();
|
|
520
|
+
if (!queryId) {
|
|
521
|
+
throw new Error('Database validation preview requires query_id.');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const startedAt = Date.now();
|
|
525
|
+
const catalog = loadDatabaseValidationCatalog(options);
|
|
526
|
+
const queryEntry = getQueryEntry(catalog, queryId);
|
|
527
|
+
const timeoutMs = Number(validation?.timeout_ms || validation?.timeoutMs || process.env.ARCALITY_DB_TIMEOUT_MS || DEFAULT_TIMEOUT_MS);
|
|
528
|
+
const provider = resolveDatabaseProvider(options);
|
|
529
|
+
const normalizedValidation = {
|
|
530
|
+
...validation,
|
|
531
|
+
query_id: queryId,
|
|
532
|
+
name: String(validation?.name || queryId || 'database_validation_preview'),
|
|
533
|
+
params: isPlainObject(validation?.params) ? validation.params : {}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const execution = provider === 'mcp'
|
|
537
|
+
? await queryDatabaseRowsViaMcp({ validation: normalizedValidation, queryEntry, timeoutMs, startedAt, options: { ...options, catalogPath: catalog.catalogPath } })
|
|
538
|
+
: await queryDatabaseRowsViaPostgres({ validation: normalizedValidation, queryEntry, timeoutMs, startedAt, options: { ...options, catalogPath: catalog.catalogPath } });
|
|
539
|
+
|
|
540
|
+
return buildDatabaseValidationPreview(normalizedValidation, execution.rows, queryEntry, execution.metadata);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export async function runDatabaseValidations(validations, options = {}) {
|
|
544
|
+
const list = Array.isArray(validations) ? validations : [];
|
|
545
|
+
const startedAt = Date.now();
|
|
546
|
+
const results = [];
|
|
547
|
+
|
|
548
|
+
for (const validation of list) {
|
|
549
|
+
try {
|
|
550
|
+
results.push(await executeDatabaseValidation(validation, options));
|
|
551
|
+
} catch (error) {
|
|
552
|
+
results.push({
|
|
553
|
+
name: String(validation?.name || validation?.query_id || validation?.queryId || 'database_validation'),
|
|
554
|
+
query_id: String(validation?.query_id || validation?.queryId || ''),
|
|
555
|
+
status: 'failed',
|
|
556
|
+
passed: false,
|
|
557
|
+
row_count: 0,
|
|
558
|
+
checks: [],
|
|
559
|
+
rows_sample: [],
|
|
560
|
+
error: error instanceof Error ? error.message : String(error),
|
|
561
|
+
executed_at: new Date().toISOString()
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const passedCount = results.filter(result => result.passed).length;
|
|
567
|
+
return {
|
|
568
|
+
type: 'database_validation_report',
|
|
569
|
+
status: passedCount === results.length ? 'passed' : 'failed',
|
|
570
|
+
passed: results.length > 0 && passedCount === results.length,
|
|
571
|
+
summary: {
|
|
572
|
+
total: results.length,
|
|
573
|
+
passed: passedCount,
|
|
574
|
+
failed: results.length - passedCount
|
|
575
|
+
},
|
|
576
|
+
duration_ms: Date.now() - startedAt,
|
|
577
|
+
executed_at: new Date().toISOString(),
|
|
578
|
+
validations: results
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function parseDatabaseValidationsFromEnv(env = process.env) {
|
|
583
|
+
const raw = env.ARCALITY_DB_VALIDATIONS_JSON || env.ARCALITY_DB_VALIDATIONS || '';
|
|
584
|
+
if (!raw.trim()) return [];
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
const parsed = JSON.parse(raw);
|
|
588
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
589
|
+
} catch {
|
|
590
|
+
return [];
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export async function runDatabaseValidationsFromEnv(options = {}) {
|
|
595
|
+
const validations = parseDatabaseValidationsFromEnv();
|
|
596
|
+
if (validations.length === 0) return null;
|
|
597
|
+
return runDatabaseValidations(validations, options);
|
|
598
|
+
}
|