@dypai-ai/mcp 1.5.11 → 1.5.13
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/package.json +1 -1
- package/src/index.js +1 -1
- package/src/tools/sync/validate.js +554 -6
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1121,7 +1121,7 @@ async function handleRequest(msg) {
|
|
|
1121
1121
|
return makeResponse(id, {
|
|
1122
1122
|
protocolVersion: "2024-11-05",
|
|
1123
1123
|
capabilities: { tools: {} },
|
|
1124
|
-
serverInfo: { name: "dypai", version: "1.
|
|
1124
|
+
serverInfo: { name: "dypai", version: "1.5.13" },
|
|
1125
1125
|
instructions: SERVER_INSTRUCTIONS,
|
|
1126
1126
|
})
|
|
1127
1127
|
}
|
|
@@ -252,7 +252,10 @@ const RUNTIME_PLACEHOLDER_OPERATOR_RE = /(\|\||&&|\?\?|[?:+*/=<>!()]|\s+\b(?:or|
|
|
|
252
252
|
|
|
253
253
|
function unsupportedRuntimePlaceholder(expr) {
|
|
254
254
|
const clean = expr.trim()
|
|
255
|
-
|
|
255
|
+
if (expr !== clean) return true
|
|
256
|
+
if (RUNTIME_PLACEHOLDER_PATH_RE.test(clean)) return false
|
|
257
|
+
if (!RUNTIME_PLACEHOLDER_OPERATOR_RE.test(clean)) return true
|
|
258
|
+
return extractRuntimePaths(clean).length === 0
|
|
256
259
|
}
|
|
257
260
|
|
|
258
261
|
/** Minimal Levenshtein distance, caps at 3 for "did you mean" typo suggestions. */
|
|
@@ -287,6 +290,31 @@ async function readSchemaTables(rootDir) {
|
|
|
287
290
|
}
|
|
288
291
|
}
|
|
289
292
|
|
|
293
|
+
/** Parse schema.sql to extract public table columns when available. */
|
|
294
|
+
async function readSchemaColumns(rootDir) {
|
|
295
|
+
try {
|
|
296
|
+
const raw = await readFile(join(rootDir, "schema.sql"), "utf8")
|
|
297
|
+
const tables = {}
|
|
298
|
+
const tableRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?public\.(?:"([^"]+)"|([A-Za-z_]\w*))\s*\(([\s\S]*?)\);/gi
|
|
299
|
+
let m
|
|
300
|
+
while ((m = tableRe.exec(raw)) !== null) {
|
|
301
|
+
const tableName = m[1] || m[2]
|
|
302
|
+
const body = m[3] || ""
|
|
303
|
+
const cols = new Set()
|
|
304
|
+
for (const rawLine of body.split("\n")) {
|
|
305
|
+
const line = rawLine.trim().replace(/,$/, "")
|
|
306
|
+
if (!line || /^(CONSTRAINT|PRIMARY|FOREIGN|UNIQUE|CHECK|EXCLUDE)\b/i.test(line)) continue
|
|
307
|
+
const col = /^"?([A-Za-z_]\w*)"?\s+/.exec(line)?.[1]
|
|
308
|
+
if (col) cols.add(col)
|
|
309
|
+
}
|
|
310
|
+
tables[tableName] = cols
|
|
311
|
+
}
|
|
312
|
+
return tables
|
|
313
|
+
} catch {
|
|
314
|
+
return {}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
290
318
|
/** Extract referenced table names from a SQL string: `FROM public.X`, `JOIN public.X`, `INTO public.X`, `UPDATE public.X`. */
|
|
291
319
|
function extractSqlTables(sql) {
|
|
292
320
|
const tables = new Set()
|
|
@@ -353,6 +381,502 @@ function lookupFileMapContent(fileMap, ref) {
|
|
|
353
381
|
return fileMap[key] || fileMap[key.replace(/^dypai\//, "")] || fileMap[`dypai/${key}`] || ""
|
|
354
382
|
}
|
|
355
383
|
|
|
384
|
+
function stripSqlForInference(sql) {
|
|
385
|
+
return String(sql || "")
|
|
386
|
+
.replace(/--[^\n]*/g, " ")
|
|
387
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
388
|
+
.replace(/'(?:[^']|'')*'/g, "''")
|
|
389
|
+
.replace(/\s+/g, " ")
|
|
390
|
+
.trim()
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function isWordAt(sql, index, word) {
|
|
394
|
+
if (sql.slice(index, index + word.length).toLowerCase() !== word) return false
|
|
395
|
+
const before = index === 0 ? "" : sql[index - 1]
|
|
396
|
+
const after = sql[index + word.length] || ""
|
|
397
|
+
return !/[A-Za-z0-9_]/.test(before) && !/[A-Za-z0-9_]/.test(after)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function skipDoubleQuotedIdentifier(sql, index) {
|
|
401
|
+
if (sql[index] !== "\"") return index
|
|
402
|
+
for (let i = index + 1; i < sql.length; i++) {
|
|
403
|
+
if (sql[i] !== "\"") continue
|
|
404
|
+
if (sql[i + 1] === "\"") {
|
|
405
|
+
i++
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
408
|
+
return i
|
|
409
|
+
}
|
|
410
|
+
return sql.length - 1
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function skipSqlSpaces(sql, index) {
|
|
414
|
+
let i = index
|
|
415
|
+
while (i < sql.length && /\s/.test(sql[i])) i++
|
|
416
|
+
return i
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function findMatchingParen(sql, openIndex) {
|
|
420
|
+
if (sql[openIndex] !== "(") return -1
|
|
421
|
+
let depth = 0
|
|
422
|
+
for (let i = openIndex; i < sql.length; i++) {
|
|
423
|
+
const ch = sql[i]
|
|
424
|
+
if (ch === "\"") {
|
|
425
|
+
i = skipDoubleQuotedIdentifier(sql, i)
|
|
426
|
+
continue
|
|
427
|
+
}
|
|
428
|
+
if (ch === "(") {
|
|
429
|
+
depth++
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
if (ch === ")") {
|
|
433
|
+
depth--
|
|
434
|
+
if (depth === 0) return i
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return -1
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function findOuterSelectList(sqlText) {
|
|
441
|
+
const sql = stripSqlForInference(sqlText)
|
|
442
|
+
if (!sql) return null
|
|
443
|
+
let depth = 0
|
|
444
|
+
let selectStart = -1
|
|
445
|
+
for (let i = 0; i < sql.length; i++) {
|
|
446
|
+
const ch = sql[i]
|
|
447
|
+
if (ch === "\"") {
|
|
448
|
+
i = skipDoubleQuotedIdentifier(sql, i)
|
|
449
|
+
continue
|
|
450
|
+
}
|
|
451
|
+
if (ch === "(") depth++
|
|
452
|
+
else if (ch === ")") depth = Math.max(0, depth - 1)
|
|
453
|
+
|
|
454
|
+
if (depth !== 0) continue
|
|
455
|
+
if (selectStart < 0 && isWordAt(sql, i, "select")) {
|
|
456
|
+
selectStart = i + "select".length
|
|
457
|
+
i += "select".length - 1
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
if (selectStart >= 0 && isWordAt(sql, i, "from")) {
|
|
461
|
+
return sql.slice(selectStart, i).trim()
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (selectStart >= 0) return sql.slice(selectStart).trim()
|
|
465
|
+
return null
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function hasTopLevelLimitOne(sqlText) {
|
|
469
|
+
const sql = stripSqlForInference(sqlText)
|
|
470
|
+
let depth = 0
|
|
471
|
+
for (let i = 0; i < sql.length; i++) {
|
|
472
|
+
const ch = sql[i]
|
|
473
|
+
if (ch === "\"") {
|
|
474
|
+
i = skipDoubleQuotedIdentifier(sql, i)
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
477
|
+
if (ch === "(") {
|
|
478
|
+
depth++
|
|
479
|
+
continue
|
|
480
|
+
}
|
|
481
|
+
if (ch === ")") {
|
|
482
|
+
depth = Math.max(0, depth - 1)
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
if (depth !== 0 || !isWordAt(sql, i, "limit")) continue
|
|
486
|
+
|
|
487
|
+
const tail = sql.slice(skipSqlSpaces(sql, i + "limit".length))
|
|
488
|
+
return /^1(?:\D|$)/.test(tail)
|
|
489
|
+
}
|
|
490
|
+
return false
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function hasTopLevelFetchOne(sqlText) {
|
|
494
|
+
const sql = stripSqlForInference(sqlText)
|
|
495
|
+
let depth = 0
|
|
496
|
+
for (let i = 0; i < sql.length; i++) {
|
|
497
|
+
const ch = sql[i]
|
|
498
|
+
if (ch === "\"") {
|
|
499
|
+
i = skipDoubleQuotedIdentifier(sql, i)
|
|
500
|
+
continue
|
|
501
|
+
}
|
|
502
|
+
if (ch === "(") {
|
|
503
|
+
depth++
|
|
504
|
+
continue
|
|
505
|
+
}
|
|
506
|
+
if (ch === ")") {
|
|
507
|
+
depth = Math.max(0, depth - 1)
|
|
508
|
+
continue
|
|
509
|
+
}
|
|
510
|
+
if (depth !== 0 || !isWordAt(sql, i, "fetch")) continue
|
|
511
|
+
|
|
512
|
+
let cursor = skipSqlSpaces(sql, i + "fetch".length)
|
|
513
|
+
if (isWordAt(sql, cursor, "first")) cursor += "first".length
|
|
514
|
+
else if (isWordAt(sql, cursor, "next")) cursor += "next".length
|
|
515
|
+
else continue
|
|
516
|
+
cursor = skipSqlSpaces(sql, cursor)
|
|
517
|
+
return /^1(?:\D|$)/.test(sql.slice(cursor))
|
|
518
|
+
}
|
|
519
|
+
return false
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function hasTopLevelGroupBy(sqlText) {
|
|
523
|
+
const sql = stripSqlForInference(sqlText)
|
|
524
|
+
let depth = 0
|
|
525
|
+
for (let i = 0; i < sql.length; i++) {
|
|
526
|
+
const ch = sql[i]
|
|
527
|
+
if (ch === "\"") {
|
|
528
|
+
i = skipDoubleQuotedIdentifier(sql, i)
|
|
529
|
+
continue
|
|
530
|
+
}
|
|
531
|
+
if (ch === "(") {
|
|
532
|
+
depth++
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
if (ch === ")") {
|
|
536
|
+
depth = Math.max(0, depth - 1)
|
|
537
|
+
continue
|
|
538
|
+
}
|
|
539
|
+
if (depth !== 0 || !isWordAt(sql, i, "group")) continue
|
|
540
|
+
|
|
541
|
+
const restStart = i + "group".length
|
|
542
|
+
const rest = sql.slice(restStart)
|
|
543
|
+
const byOffset = rest.search(/\S/)
|
|
544
|
+
if (byOffset >= 0 && isWordAt(sql, restStart + byOffset, "by")) return true
|
|
545
|
+
}
|
|
546
|
+
return false
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function hasTopLevelSetOperation(sqlText) {
|
|
550
|
+
const sql = stripSqlForInference(sqlText)
|
|
551
|
+
let depth = 0
|
|
552
|
+
for (let i = 0; i < sql.length; i++) {
|
|
553
|
+
const ch = sql[i]
|
|
554
|
+
if (ch === "\"") {
|
|
555
|
+
i = skipDoubleQuotedIdentifier(sql, i)
|
|
556
|
+
continue
|
|
557
|
+
}
|
|
558
|
+
if (ch === "(") {
|
|
559
|
+
depth++
|
|
560
|
+
continue
|
|
561
|
+
}
|
|
562
|
+
if (ch === ")") {
|
|
563
|
+
depth = Math.max(0, depth - 1)
|
|
564
|
+
continue
|
|
565
|
+
}
|
|
566
|
+
if (depth !== 0) continue
|
|
567
|
+
if (isWordAt(sql, i, "union") || isWordAt(sql, i, "intersect") || isWordAt(sql, i, "except")) {
|
|
568
|
+
return true
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return false
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const AGGREGATE_FUNCTIONS = ["count", "sum", "avg", "min", "max", "json_agg", "jsonb_agg", "array_agg", "string_agg"]
|
|
575
|
+
|
|
576
|
+
function isWindowAggregateCall(sql, openIndex) {
|
|
577
|
+
const closeIndex = findMatchingParen(sql, openIndex)
|
|
578
|
+
if (closeIndex < 0) return false
|
|
579
|
+
|
|
580
|
+
let cursor = skipSqlSpaces(sql, closeIndex + 1)
|
|
581
|
+
if (isWordAt(sql, cursor, "filter")) {
|
|
582
|
+
cursor = skipSqlSpaces(sql, cursor + "filter".length)
|
|
583
|
+
if (sql[cursor] === "(") {
|
|
584
|
+
const filterClose = findMatchingParen(sql, cursor)
|
|
585
|
+
if (filterClose >= 0) cursor = skipSqlSpaces(sql, filterClose + 1)
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return isWordAt(sql, cursor, "over")
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function hasOuterAggregateFunction(selectList) {
|
|
593
|
+
const sql = String(selectList || "")
|
|
594
|
+
let depth = 0
|
|
595
|
+
const activeSubqueryDepths = []
|
|
596
|
+
|
|
597
|
+
const insideSubquery = () => activeSubqueryDepths.some(d => d <= depth)
|
|
598
|
+
|
|
599
|
+
for (let i = 0; i < sql.length; i++) {
|
|
600
|
+
const ch = sql[i]
|
|
601
|
+
if (ch === "\"") {
|
|
602
|
+
i = skipDoubleQuotedIdentifier(sql, i)
|
|
603
|
+
continue
|
|
604
|
+
}
|
|
605
|
+
if (ch === "(") {
|
|
606
|
+
depth++
|
|
607
|
+
continue
|
|
608
|
+
}
|
|
609
|
+
if (ch === ")") {
|
|
610
|
+
activeSubqueryDepths.splice(0, activeSubqueryDepths.length, ...activeSubqueryDepths.filter(d => d < depth))
|
|
611
|
+
depth = Math.max(0, depth - 1)
|
|
612
|
+
continue
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (depth > 0 && isWordAt(sql, i, "select")) {
|
|
616
|
+
activeSubqueryDepths.push(depth)
|
|
617
|
+
i += "select".length - 1
|
|
618
|
+
continue
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
for (const fn of AGGREGATE_FUNCTIONS) {
|
|
622
|
+
if (!isWordAt(sql, i, fn)) continue
|
|
623
|
+
const openIndex = skipSqlSpaces(sql, i + fn.length)
|
|
624
|
+
if (sql[openIndex] === "(" && !insideSubquery() && !isWindowAggregateCall(sql, openIndex)) return true
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return false
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function splitTopLevelComma(list) {
|
|
632
|
+
const parts = []
|
|
633
|
+
let depth = 0
|
|
634
|
+
let start = 0
|
|
635
|
+
for (let i = 0; i < list.length; i++) {
|
|
636
|
+
const ch = list[i]
|
|
637
|
+
if (ch === "\"") {
|
|
638
|
+
i = skipDoubleQuotedIdentifier(list, i)
|
|
639
|
+
continue
|
|
640
|
+
}
|
|
641
|
+
if (ch === "(") depth++
|
|
642
|
+
else if (ch === ")") depth = Math.max(0, depth - 1)
|
|
643
|
+
else if (ch === "," && depth === 0) {
|
|
644
|
+
parts.push(list.slice(start, i).trim())
|
|
645
|
+
start = i + 1
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const tail = list.slice(start).trim()
|
|
649
|
+
if (tail) parts.push(tail)
|
|
650
|
+
return parts
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function inferSelectOutputColumns(sqlText) {
|
|
654
|
+
const selectList = findOuterSelectList(sqlText)
|
|
655
|
+
if (!selectList) return { properties: null, strict: false }
|
|
656
|
+
|
|
657
|
+
const props = new Set()
|
|
658
|
+
let strict = true
|
|
659
|
+
for (const itemRaw of splitTopLevelComma(selectList)) {
|
|
660
|
+
const item = itemRaw.trim()
|
|
661
|
+
if (!item || item === "*" || /\.\*$/.test(item)) {
|
|
662
|
+
strict = false
|
|
663
|
+
continue
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const asAlias = /\bas\s+"?([A-Za-z_]\w*)"?$/i.exec(item)?.[1]
|
|
667
|
+
if (asAlias) {
|
|
668
|
+
props.add(asAlias)
|
|
669
|
+
continue
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const simpleColumn = /^(?:(?:"?[A-Za-z_]\w*"?\.)?)"?([A-Za-z_]\w*)"?$/.exec(item)?.[1]
|
|
673
|
+
if (simpleColumn) {
|
|
674
|
+
props.add(simpleColumn)
|
|
675
|
+
continue
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const trailingAlias = /\s+"?([A-Za-z_]\w*)"?$/.exec(item)?.[1]
|
|
679
|
+
if (trailingAlias && !SQL_KEYWORDS_AFTER_FROM.has(trailingAlias.toUpperCase())) {
|
|
680
|
+
props.add(trailingAlias)
|
|
681
|
+
continue
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
strict = false
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return { properties: props.size > 0 ? props : null, strict }
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function inferSqlCardinality(sqlText) {
|
|
691
|
+
const sql = stripSqlForInference(sqlText)
|
|
692
|
+
if (!sql) return null
|
|
693
|
+
if (hasTopLevelLimitOne(sql) || hasTopLevelFetchOne(sql)) return "single"
|
|
694
|
+
const startsLikeRead = /^(select|with)\b/i.test(sql)
|
|
695
|
+
if (startsLikeRead && hasTopLevelSetOperation(sql)) return "many"
|
|
696
|
+
const outerSelectList = findOuterSelectList(sql)
|
|
697
|
+
const hasAggregate = outerSelectList ? hasOuterAggregateFunction(outerSelectList) : false
|
|
698
|
+
const hasGroupBy = hasTopLevelGroupBy(sql)
|
|
699
|
+
if (startsLikeRead && hasAggregate && !hasGroupBy) return "single"
|
|
700
|
+
return "many"
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function returningColumns(returning, table, schemaColumns) {
|
|
704
|
+
if (Array.isArray(returning)) return new Set(returning.filter(v => typeof v === "string" && v !== "*"))
|
|
705
|
+
if (typeof returning === "string" && returning.trim() && returning.trim() !== "*") {
|
|
706
|
+
return new Set(returning.split(",").map(s => s.trim()).filter(Boolean))
|
|
707
|
+
}
|
|
708
|
+
if (table && schemaColumns?.[table]?.size) return new Set(schemaColumns[table])
|
|
709
|
+
return null
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function buildNodeOutputContracts(nodes, fileMap, ctx) {
|
|
713
|
+
const contracts = new Map()
|
|
714
|
+
for (const node of nodes || []) {
|
|
715
|
+
if (!node || typeof node !== "object" || !node.id) continue
|
|
716
|
+
const nodeType = node.type ?? node.node_type
|
|
717
|
+
const params = nodeParams(node)
|
|
718
|
+
|
|
719
|
+
if (nodeType === "set_fields") {
|
|
720
|
+
const op = nodeField(node, params, "operation") || "set"
|
|
721
|
+
const fields = nodeField(node, params, "fields")
|
|
722
|
+
if (fields && typeof fields === "object" && !Array.isArray(fields)) {
|
|
723
|
+
contracts.set(node.id, {
|
|
724
|
+
type: "object",
|
|
725
|
+
properties: new Set(Object.keys(fields).map(k => k.split(".")[0])),
|
|
726
|
+
strict: op === "compose",
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
continue
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (nodeType !== "dypai_database") continue
|
|
733
|
+
const op = nodeField(node, params, "operation")
|
|
734
|
+
const table = nodeField(node, params, "table") ?? nodeField(node, params, "table_name")
|
|
735
|
+
|
|
736
|
+
if (op === "query" || op === "custom_query") {
|
|
737
|
+
const query = nodeField(node, params, "query") || lookupFileMapContent(fileMap, nodeField(node, params, "query_file"))
|
|
738
|
+
const inferred = inferSelectOutputColumns(query)
|
|
739
|
+
contracts.set(node.id, {
|
|
740
|
+
type: "array",
|
|
741
|
+
cardinality: inferSqlCardinality(query),
|
|
742
|
+
properties: inferred.properties,
|
|
743
|
+
strict: inferred.strict,
|
|
744
|
+
})
|
|
745
|
+
continue
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (op === "select") {
|
|
749
|
+
contracts.set(node.id, {
|
|
750
|
+
type: "array",
|
|
751
|
+
cardinality: "many",
|
|
752
|
+
properties: table ? ctx.schemaColumns?.[table] ?? null : null,
|
|
753
|
+
strict: Boolean(table && ctx.schemaColumns?.[table]),
|
|
754
|
+
})
|
|
755
|
+
continue
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (op === "aggregate") {
|
|
759
|
+
contracts.set(node.id, { type: "object", properties: new Set(["value"]), strict: true })
|
|
760
|
+
continue
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (op === "insert") {
|
|
764
|
+
const bulk = nodeField(node, params, "mode") === "bulk" || Array.isArray(nodeField(node, params, "data"))
|
|
765
|
+
contracts.set(node.id, {
|
|
766
|
+
type: bulk ? "array" : "object",
|
|
767
|
+
cardinality: bulk ? "many" : "single",
|
|
768
|
+
properties: table ? ctx.schemaColumns?.[table] ?? null : null,
|
|
769
|
+
strict: Boolean(table && ctx.schemaColumns?.[table]),
|
|
770
|
+
})
|
|
771
|
+
continue
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (op === "upsert") {
|
|
775
|
+
contracts.set(node.id, {
|
|
776
|
+
type: "object",
|
|
777
|
+
cardinality: "single",
|
|
778
|
+
properties: table ? ctx.schemaColumns?.[table] ?? null : null,
|
|
779
|
+
strict: Boolean(table && ctx.schemaColumns?.[table]),
|
|
780
|
+
})
|
|
781
|
+
continue
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (op === "update" || op === "delete" || op === "copy_to") {
|
|
785
|
+
contracts.set(node.id, {
|
|
786
|
+
type: "array",
|
|
787
|
+
cardinality: "many",
|
|
788
|
+
properties: table ? ctx.schemaColumns?.[table] ?? null : null,
|
|
789
|
+
strict: Boolean(table && ctx.schemaColumns?.[table]),
|
|
790
|
+
})
|
|
791
|
+
continue
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (op === "mutation") {
|
|
795
|
+
const insertValue = nodeField(node, params, "insert")
|
|
796
|
+
const updateValue = nodeField(node, params, "update")
|
|
797
|
+
const deleteValue = nodeField(node, params, "delete")
|
|
798
|
+
const returning = nodeField(node, params, "returning") ?? "*"
|
|
799
|
+
const props = returningColumns(returning, table, ctx.schemaColumns)
|
|
800
|
+
if (insertValue !== undefined && insertValue !== null) {
|
|
801
|
+
const bulk = nodeField(node, params, "mode") === "bulk" || Array.isArray(insertValue)
|
|
802
|
+
const hasConflict = nodeField(node, params, "on_conflict") !== undefined
|
|
803
|
+
contracts.set(node.id, {
|
|
804
|
+
type: bulk || hasConflict ? "array" : "object",
|
|
805
|
+
cardinality: bulk ? "many" : hasConflict ? "zero_or_one" : "single",
|
|
806
|
+
properties: props,
|
|
807
|
+
strict: Boolean(props),
|
|
808
|
+
})
|
|
809
|
+
} else if (updateValue !== undefined || deleteValue === true) {
|
|
810
|
+
contracts.set(node.id, {
|
|
811
|
+
type: "array",
|
|
812
|
+
cardinality: "many",
|
|
813
|
+
properties: props,
|
|
814
|
+
strict: Boolean(props),
|
|
815
|
+
})
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return contracts
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function extractRuntimePaths(expr) {
|
|
823
|
+
const clean = String(expr || "").replace(/'(?:[^']|'')*'|"(?:[^"\\]|\\.)*"/g, " ")
|
|
824
|
+
const paths = []
|
|
825
|
+
const re = /\b(?:input|nodes|vars|current_user)\.[A-Za-z_][A-Za-z0-9_-]*(?:\[[0-9]+\]|\.[A-Za-z_][A-Za-z0-9_-]*)*|\bcurrent_user_id\b|\bcurrent_user_role\b/g
|
|
826
|
+
let m
|
|
827
|
+
while ((m = re.exec(clean)) !== null) paths.push(m[0])
|
|
828
|
+
if (paths.length === 0 && RUNTIME_PLACEHOLDER_PATH_RE.test(expr.trim())) paths.push(expr.trim())
|
|
829
|
+
return [...new Set(paths)]
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function parseNodeOutputRef(path) {
|
|
833
|
+
const m = /^nodes\.([A-Za-z_][A-Za-z0-9_-]*)(.*)$/.exec(path)
|
|
834
|
+
if (!m) return null
|
|
835
|
+
const nodeId = m[1]
|
|
836
|
+
let rest = m[2] || ""
|
|
837
|
+
let indexed = false
|
|
838
|
+
if (rest.startsWith("[")) {
|
|
839
|
+
const idx = /^\[[0-9]+\]/.exec(rest)?.[0]
|
|
840
|
+
if (idx) {
|
|
841
|
+
indexed = true
|
|
842
|
+
rest = rest.slice(idx.length)
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
const prop = rest.startsWith(".") ? /^\.([A-Za-z_][A-Za-z0-9_-]*)/.exec(rest)?.[1] : null
|
|
846
|
+
return { nodeId, indexed, prop }
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function validateNodeOutputRef({ expr, path, contract, endpoint, file, loc }) {
|
|
850
|
+
const ref = parseNodeOutputRef(path)
|
|
851
|
+
if (!ref || !ref.prop || !contract) return null
|
|
852
|
+
|
|
853
|
+
if (contract.strict && contract.properties && !contract.properties.has(ref.prop)) {
|
|
854
|
+
const known = [...contract.properties]
|
|
855
|
+
const suggestions = known.filter(k => levenshteinSmall(k, ref.prop) <= 2).slice(0, 3)
|
|
856
|
+
return {
|
|
857
|
+
severity: "warn",
|
|
858
|
+
rule: "node_output_property_unknown",
|
|
859
|
+
endpoint, file, loc,
|
|
860
|
+
message: `\${${expr}} references '${ref.prop}', but node '${ref.nodeId}' is inferred to output: ${known.join(", ") || "(no known properties)"}.`,
|
|
861
|
+
fix_hint: suggestions.length
|
|
862
|
+
? `Did you mean: ${suggestions.join(", ")}?`
|
|
863
|
+
: `Use one of: ${known.join(", ") || "(none)"}. If this is a dynamic SQL shape, verify with dypai_test_endpoint.`,
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (contract.type === "array" && contract.cardinality === "many" && !ref.indexed) {
|
|
868
|
+
return {
|
|
869
|
+
severity: "warn",
|
|
870
|
+
rule: "node_output_many_direct_property",
|
|
871
|
+
endpoint, file, loc,
|
|
872
|
+
message: `\${${expr}} reads property '${ref.prop}' directly from node '${ref.nodeId}', but that node is inferred to return many rows.`,
|
|
873
|
+
fix_hint: `Use \${nodes.${ref.nodeId}} for the full array, or \${nodes.${ref.nodeId}[0].${ref.prop}} if you intentionally want the first row.`,
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
return null
|
|
878
|
+
}
|
|
879
|
+
|
|
356
880
|
function collectMissingTableCandidates(ctx, referencedTables, endpoint, file) {
|
|
357
881
|
if (!ctx.schemaTables) return
|
|
358
882
|
for (const table of referencedTables) {
|
|
@@ -377,6 +901,7 @@ function validateEndpoint(entry, ctx) {
|
|
|
377
901
|
|
|
378
902
|
const inputProps = doc.input?.properties || {}
|
|
379
903
|
const nodeIds = new Set((doc.workflow?.nodes || []).map(n => n.id))
|
|
904
|
+
const outputContracts = buildNodeOutputContracts(doc.workflow?.nodes || [], fileMap, ctx)
|
|
380
905
|
|
|
381
906
|
const jwt = ruleUsesJwt(doc.trigger)
|
|
382
907
|
|
|
@@ -409,6 +934,7 @@ function validateEndpoint(entry, ctx) {
|
|
|
409
934
|
for (const { source, loc, value } of sources) {
|
|
410
935
|
// --- Placeholder checks ---
|
|
411
936
|
for (const expr of extractPlaceholders(value)) {
|
|
937
|
+
const runtimePaths = extractRuntimePaths(expr)
|
|
412
938
|
if (unsupportedRuntimePlaceholder(expr)) {
|
|
413
939
|
diagnostics.push({
|
|
414
940
|
severity: "error",
|
|
@@ -423,10 +949,10 @@ function validateEndpoint(entry, ctx) {
|
|
|
423
949
|
continue
|
|
424
950
|
}
|
|
425
951
|
|
|
426
|
-
// Normalize
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
const e
|
|
952
|
+
// Normalize every runtime path we can find in this placeholder. Expressions
|
|
953
|
+
// such as `${nodes.x.stock >= input.qty}` are valid at runtime, so validate
|
|
954
|
+
// each referenced path instead of rejecting the whole expression.
|
|
955
|
+
for (const e of runtimePaths) {
|
|
430
956
|
|
|
431
957
|
// ${input.X} or ${input.X.Y}
|
|
432
958
|
// Only validate against the input schema if one is declared; DYPAI allows
|
|
@@ -462,6 +988,16 @@ function validateEndpoint(entry, ctx) {
|
|
|
462
988
|
if (!missingNodeRefs.has(nodeId)) {
|
|
463
989
|
missingNodeRefs.set(nodeId, { loc, expr })
|
|
464
990
|
}
|
|
991
|
+
} else {
|
|
992
|
+
const outputDiag = validateNodeOutputRef({
|
|
993
|
+
expr,
|
|
994
|
+
path: e,
|
|
995
|
+
contract: outputContracts.get(nodeId),
|
|
996
|
+
endpoint: name,
|
|
997
|
+
file,
|
|
998
|
+
loc,
|
|
999
|
+
})
|
|
1000
|
+
if (outputDiag) diagnostics.push(outputDiag)
|
|
465
1001
|
}
|
|
466
1002
|
}
|
|
467
1003
|
|
|
@@ -477,6 +1013,7 @@ function validateEndpoint(entry, ctx) {
|
|
|
477
1013
|
})
|
|
478
1014
|
}
|
|
479
1015
|
}
|
|
1016
|
+
}
|
|
480
1017
|
}
|
|
481
1018
|
|
|
482
1019
|
// NOTE: SQL table extraction used to live here (anywhere a string looked
|
|
@@ -1241,10 +1778,11 @@ export async function runValidation(rootDir, projectId) {
|
|
|
1241
1778
|
const config = await readLocalConfig(rootDir)
|
|
1242
1779
|
const targetProjectId = projectId || config?.project_id || null
|
|
1243
1780
|
|
|
1244
|
-
const [local, remote, schemaTables, nodeCatalog] = await Promise.all([
|
|
1781
|
+
const [local, remote, schemaTables, schemaColumns, nodeCatalog] = await Promise.all([
|
|
1245
1782
|
readLocalState(rootDir),
|
|
1246
1783
|
fetchRemoteState(targetProjectId),
|
|
1247
1784
|
readSchemaTables(rootDir),
|
|
1785
|
+
readSchemaColumns(rootDir),
|
|
1248
1786
|
loadNodeCatalog(rootDir),
|
|
1249
1787
|
])
|
|
1250
1788
|
|
|
@@ -1261,6 +1799,7 @@ export async function runValidation(rootDir, projectId) {
|
|
|
1261
1799
|
remoteCredentials,
|
|
1262
1800
|
toolEndpoints,
|
|
1263
1801
|
schemaTables,
|
|
1802
|
+
schemaColumns,
|
|
1264
1803
|
catalog: nodeCatalog.schemas,
|
|
1265
1804
|
knownTypes: nodeCatalog.knownTypes,
|
|
1266
1805
|
fileByName: Object.fromEntries(Object.values(local.byName).map(e => [e.doc.name, `endpoints/${e.file || `${e.doc.name}.yaml`}`])),
|
|
@@ -1370,6 +1909,15 @@ export async function runValidation(rootDir, projectId) {
|
|
|
1370
1909
|
}
|
|
1371
1910
|
}
|
|
1372
1911
|
|
|
1912
|
+
export const __testing = {
|
|
1913
|
+
buildNodeOutputContracts,
|
|
1914
|
+
extractRuntimePaths,
|
|
1915
|
+
inferSelectOutputColumns,
|
|
1916
|
+
inferSqlCardinality,
|
|
1917
|
+
unsupportedRuntimePlaceholder,
|
|
1918
|
+
validateNodeOutputRef,
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1373
1921
|
export const dypaiValidateTool = {
|
|
1374
1922
|
name: "dypai_validate",
|
|
1375
1923
|
description:
|