@arcadialdev/arcality 4.0.2 → 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.
Files changed (36) hide show
  1. package/.agents/skills/db-validation-evidence/SKILL.md +43 -0
  2. package/.agents/skills/db-validation-evidence/agents/openai.yaml +4 -0
  3. package/.agents/skills/db-validation-evidence/references/mission-patterns.md +130 -0
  4. package/.agents/skills/evidence-synthesis-reporting/SKILL.md +31 -0
  5. package/.agents/skills/form-expert/SKILL.md +98 -0
  6. package/.agents/skills/investigation-protocol/SKILL.md +56 -0
  7. package/.agents/skills/mission-generation-reviewer/SKILL.md +25 -0
  8. package/.agents/skills/modal-master/SKILL.md +46 -0
  9. package/.agents/skills/native-control-expert/SKILL.md +74 -0
  10. package/.agents/skills/qa-context-governance/SKILL.md +23 -0
  11. package/.agents/skills/security-evidence-triage/SKILL.md +23 -0
  12. package/.agents/skills/test-data-lifecycle/SKILL.md +24 -0
  13. package/README.md +103 -163
  14. package/bin/arcality.mjs +25 -25
  15. package/package.json +75 -75
  16. package/scripts/edit-config.mjs +843 -0
  17. package/scripts/gen-and-run.mjs +2705 -2609
  18. package/scripts/generate.mjs +236 -236
  19. package/scripts/init.mjs +47 -47
  20. package/src/configManager.mjs +13 -13
  21. package/src/envSetup.ts +229 -205
  22. package/src/services/codebaseAnalyzer.mjs +59 -59
  23. package/src/services/databaseValidationService.mjs +598 -0
  24. package/src/services/executionEvidenceService.mjs +124 -0
  25. package/src/services/generatedMissionSchema.mjs +76 -76
  26. package/src/services/generatedMissionStore.mjs +117 -117
  27. package/src/services/generationContext.mjs +242 -242
  28. package/src/services/mcpStdioClient.mjs +204 -0
  29. package/src/services/missionGenerator.mjs +329 -329
  30. package/src/services/routeDiscovery.mjs +762 -762
  31. package/tests/_helpers/ArcalityReporter.js +1342 -1255
  32. package/tests/_helpers/agentic-runner.bundle.spec.js +1363 -245
  33. package/.agent/skills/form-expert.md +0 -102
  34. package/.agent/skills/investigation-protocol.md +0 -61
  35. package/.agent/skills/modal-master.md +0 -41
  36. 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
+ }