@dypai-ai/mcp 1.5.10 → 1.5.12
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 +23 -2
- package/src/tools/capability-kits.js +567 -0
- package/src/tools/sync/planner.js +76 -3
- package/src/tools/sync/validate.js +371 -21
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -35,6 +35,10 @@ import { manageFrontendTool } from "./tools/frontend.js"
|
|
|
35
35
|
// import { scaffoldTool } from "./tools/scaffold.js"
|
|
36
36
|
import { manageDomainTool } from "./tools/domains.js"
|
|
37
37
|
import { bulkUpsertTool } from "./tools/bulk-upsert.js"
|
|
38
|
+
import {
|
|
39
|
+
searchCapabilityKitsTool,
|
|
40
|
+
manageCapabilityKitTool,
|
|
41
|
+
} from "./tools/capability-kits.js"
|
|
38
42
|
import { uploadFile } from "./tools/storage.js"
|
|
39
43
|
// dypaiTestTool (YAML test-suite runner) is intentionally not imported — deferred to v2.
|
|
40
44
|
// The format works but needs fixtures/auto-rollback/scaffolder + proper docs before being surfaced.
|
|
@@ -82,6 +86,9 @@ const LOCAL_TOOLS = [
|
|
|
82
86
|
manageDomainTool,
|
|
83
87
|
// ── Data ──────────────────────────────────────────────────────────────────
|
|
84
88
|
bulkUpsertTool,
|
|
89
|
+
// ── Capability kits (local source installer) ──────────────────────────────
|
|
90
|
+
searchCapabilityKitsTool,
|
|
91
|
+
manageCapabilityKitTool,
|
|
85
92
|
// ── Git-first source of truth ─────────────────────────────────────────────
|
|
86
93
|
// dypai_describe was merged into dypai_pull (now returns an `overview` block).
|
|
87
94
|
dypaiPullTool,
|
|
@@ -538,6 +545,17 @@ forms, calendars, or domain-specific screens), use \`search_design_patterns\`
|
|
|
538
545
|
with the app/starter/screen/style context. It returns curated recipes; adapt
|
|
539
546
|
them to the project instead of inventing generic starter UI.
|
|
540
547
|
|
|
548
|
+
For complex reusable capabilities (calendar booking, interactive maps, CRUD
|
|
549
|
+
tables, Kanban boards, upload/document flows, dashboards, rich editors), also
|
|
550
|
+
use \`search_capability_kits\`. If a strong kit matches, inspect it with
|
|
551
|
+
\`manage_capability_kit(operation: "inspect")\` and install it with
|
|
552
|
+
\`manage_capability_kit(operation: "apply")\` instead of building that complex
|
|
553
|
+
component from scratch. Installed kit code is editable workspace source; after
|
|
554
|
+
install, if the tool returns dependencies, add any missing packages to the
|
|
555
|
+
target workspace package.json and install them locally (never globally or in
|
|
556
|
+
the MCP server). Then wire the kit into the app, apply SQL if needed, validate
|
|
557
|
+
endpoints, and verify the frontend.
|
|
558
|
+
|
|
541
559
|
**The template system exists to save time when the fit is obvious, not to force-match every request.** When in doubt → blank is always correct. Iterating up from blank is cheaper than deleting 80% of a mismatched template.
|
|
542
560
|
|
|
543
561
|
## The one legit follow-up question
|
|
@@ -1014,6 +1032,8 @@ Each item has \`type\` (\`execution\` | \`execution_failed\` | \`log\`), \`level
|
|
|
1014
1032
|
|
|
1015
1033
|
DYPAI has NO standalone "edge functions". Every piece of server-side logic (API endpoints, crons, webhooks, AI agents) is a workflow endpoint under \`dypai/endpoints/<name>.yaml\`. A workflow is either a chain of native nodes (\`dypai_database\`, \`http_request\`, \`agent\`, \`stripe\`, etc.) OR a single \`javascript_code\` / \`python_code\` node for custom logic. Mix freely.
|
|
1016
1034
|
|
|
1035
|
+
Endpoint naming is strict: the YAML \`name\` is the public API slug and must exactly match the file basename. Example: \`dypai/endpoints/list-videos.yaml\` must declare \`name: list-videos\`. Use lowercase letters, numbers, hyphens, or underscores only; never spaces or human titles. Put labels like "Listar videos" in \`description\`, not \`name\`. If \`id\` is present, it must match \`name\`; usually omit \`id\`.
|
|
1036
|
+
|
|
1017
1037
|
Mental translations: "edge function" → workflow with one code node; "cron" → \`trigger.schedule\` in the YAML; "webhook receiver" → \`trigger.webhook\`; "internal API" → \`trigger.http_api auth_mode:jwt\`.
|
|
1018
1038
|
|
|
1019
1039
|
→ Full workflow patterns + YAML shape: \`search_docs("workflow patterns")\` and \`search_docs("trigger model")\`. Node catalog (full input/output schemas): read \`dypai/node-catalog.json\`.
|
|
@@ -1039,6 +1059,7 @@ Mental translations: "edge function" → workflow with one code node; "cron" →
|
|
|
1039
1059
|
5. **Missing \`return: true\`** — endpoint returns \`null\`. Every path that should produce an HTTP response needs one node with \`return: true\`.
|
|
1040
1060
|
6. **\`tool_ids\` in YAML instead of \`tools\`** — write \`tools: [name1, name2]\`. \`tool_ids\` bypasses the codec and fails silently in prod.
|
|
1041
1061
|
7. **Putting workflow placeholders inside \`javascript_code.code\`** — code is raw JavaScript, so JS template literals like \`\${where.join(" AND ")}\` are safe and not rendered by DYPAI. Pass workflow values via \`input_data\`, \`ctx.nodes\`, \`ctx.user\`, or \`ctx.env\`; do not write \`\${input.email}\` inside code or set \`code\` from another node output.
|
|
1062
|
+
8. **Human endpoint names** — \`name: Listar videos\` in \`list-videos.yaml\` creates a draft the frontend cannot call as \`list-videos\`. \`dypai_validate\` and \`dypai_push\` reject this; fix the slug instead of testing around it.
|
|
1042
1063
|
|
|
1043
1064
|
→ Longer list of common pitfalls + fixes: \`search_docs("troubleshooting")\`.
|
|
1044
1065
|
|
|
@@ -1049,7 +1070,7 @@ Mental translations: "edge function" → workflow with one code node; "cron" →
|
|
|
1049
1070
|
- **AI chat**: single endpoint with \`agent\` node + \`memory_key: "chat:\${current_user_id}"\`. Frontend \`dypai.api.stream()\`.
|
|
1050
1071
|
- **Payments (Stripe)**: \`stripe\` node for ops. Webhook endpoint with \`trigger.webhook\` + \`stripe_webhook: true\` (auto-verifies signature).
|
|
1051
1072
|
- **Cron**: \`trigger.schedule: { cron: "0 9 * * *", timezone: "..." }\`. Same workflow syntax as HTTP endpoints.
|
|
1052
|
-
- **File upload**: frontend \`dypai.api.upload(endpoint, file)\`. Endpoint = one \`dypai_storage\` node
|
|
1073
|
+
- **File upload**: frontend \`dypai.api.upload(endpoint, file, { params: { operation:'upload', file_path } })\`. Endpoint = one \`dypai_storage\` node with \`return:true\`. SDK handles signed-URL PUT and owns \`confirm\`/\`client_upload\`; never pass those flags from frontend params.
|
|
1053
1074
|
- **Live dashboard**: normal endpoint + frontend \`useRealtime(table, { onInsert: refetch })\`.
|
|
1054
1075
|
- **Multi-tenant**: every row has \`org_id\` or \`user_id\`. Every endpoint filters by \`\${current_user_id}\`.
|
|
1055
1076
|
|
|
@@ -1100,7 +1121,7 @@ async function handleRequest(msg) {
|
|
|
1100
1121
|
return makeResponse(id, {
|
|
1101
1122
|
protocolVersion: "2024-11-05",
|
|
1102
1123
|
capabilities: { tools: {} },
|
|
1103
|
-
serverInfo: { name: "dypai", version: "1.
|
|
1124
|
+
serverInfo: { name: "dypai", version: "1.5.12" },
|
|
1104
1125
|
instructions: SERVER_INSTRUCTIONS,
|
|
1105
1126
|
})
|
|
1106
1127
|
}
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync } from "fs"
|
|
2
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "path"
|
|
3
|
+
import { fileURLToPath } from "url"
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
6
|
+
const DEFAULT_LIMIT = 5
|
|
7
|
+
|
|
8
|
+
function readJson(path) {
|
|
9
|
+
return JSON.parse(readFileSync(path, "utf8"))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isObject(value) {
|
|
13
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeList(value) {
|
|
17
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.trim()) : []
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function walkUp(start, predicate) {
|
|
21
|
+
let cursor = resolve(start)
|
|
22
|
+
for (let i = 0; i < 10; i++) {
|
|
23
|
+
if (predicate(cursor)) return cursor
|
|
24
|
+
const parent = dirname(cursor)
|
|
25
|
+
if (parent === cursor) break
|
|
26
|
+
cursor = parent
|
|
27
|
+
}
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveKitsRoot(inputRoot) {
|
|
32
|
+
const candidates = [
|
|
33
|
+
inputRoot,
|
|
34
|
+
process.env.DYPAI_CAPABILITY_KITS_ROOT,
|
|
35
|
+
].filter(Boolean)
|
|
36
|
+
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
const root = resolve(candidate)
|
|
39
|
+
if (existsSync(join(root, "kits"))) return root
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fromCwd = walkUp(process.cwd(), (dir) => existsSync(join(dir, "dypai-capability-kits", "kits")))
|
|
43
|
+
if (fromCwd) return join(fromCwd, "dypai-capability-kits")
|
|
44
|
+
|
|
45
|
+
const fromThisFile = walkUp(__dirname, (dir) => existsSync(join(dir, "dypai-capability-kits", "kits")))
|
|
46
|
+
if (fromThisFile) return join(fromThisFile, "dypai-capability-kits")
|
|
47
|
+
|
|
48
|
+
throw new Error(
|
|
49
|
+
"Capability kit repo not found. Set DYPAI_CAPABILITY_KITS_ROOT to the local dypai-capability-kits directory.",
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveWorkspaceRoot(inputRoot) {
|
|
54
|
+
if (inputRoot) {
|
|
55
|
+
const root = resolve(inputRoot)
|
|
56
|
+
mkdirSync(root, { recursive: true })
|
|
57
|
+
return root
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const envCandidates = [
|
|
61
|
+
process.env.CLAUDE_PROJECT_DIR,
|
|
62
|
+
process.env.DYPAI_WORKSPACE_ROOT,
|
|
63
|
+
process.env.PROJECT_ROOT,
|
|
64
|
+
process.env.WORKSPACE_FOLDER_PATHS?.split(delimiter)[0],
|
|
65
|
+
].filter(Boolean)
|
|
66
|
+
|
|
67
|
+
for (const candidate of envCandidates) {
|
|
68
|
+
const root = resolve(candidate)
|
|
69
|
+
if (existsSync(root)) return root
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fromCwd = walkUp(process.cwd(), (dir) =>
|
|
73
|
+
existsSync(join(dir, ".git")) ||
|
|
74
|
+
existsSync(join(dir, "package.json")) ||
|
|
75
|
+
existsSync(join(dir, "dypai")) ||
|
|
76
|
+
existsSync(join(dir, "src")),
|
|
77
|
+
)
|
|
78
|
+
if (fromCwd) return fromCwd
|
|
79
|
+
|
|
80
|
+
throw new Error("Could not determine workspace root. Pass workspace_root as an absolute project path.")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function safeTarget(workspaceRoot, targetPath) {
|
|
84
|
+
if (!targetPath || typeof targetPath !== "string") throw new Error("Invalid target path")
|
|
85
|
+
if (isAbsolute(targetPath)) throw new Error(`Target must be workspace-relative, got absolute path: ${targetPath}`)
|
|
86
|
+
const normalized = targetPath.replace(/\\/g, "/").replace(/^\/+/, "")
|
|
87
|
+
if (normalized.includes("..")) throw new Error(`Target path cannot contain '..': ${targetPath}`)
|
|
88
|
+
const full = resolve(workspaceRoot, normalized)
|
|
89
|
+
const rel = relative(workspaceRoot, full)
|
|
90
|
+
if (rel.startsWith("..") || rel === "" || isAbsolute(rel)) {
|
|
91
|
+
throw new Error(`Target escapes workspace root: ${targetPath}`)
|
|
92
|
+
}
|
|
93
|
+
return full
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function kitDir(kitsRoot, slug, version) {
|
|
97
|
+
if (!slug || !version) throw new Error("slug and version are required")
|
|
98
|
+
return join(kitsRoot, "kits", slug, version)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadKit(kitsRoot, slug, version) {
|
|
102
|
+
const dir = kitDir(kitsRoot, slug, version)
|
|
103
|
+
const manifestPath = join(dir, "kit.json")
|
|
104
|
+
if (!existsSync(manifestPath)) throw new Error(`Kit not found: ${slug}@${version}`)
|
|
105
|
+
const manifest = readJson(manifestPath)
|
|
106
|
+
return { dir, manifest }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function listKits(kitsRoot) {
|
|
110
|
+
const kitsDir = join(kitsRoot, "kits")
|
|
111
|
+
const kits = []
|
|
112
|
+
for (const slug of readdirSync(kitsDir)) {
|
|
113
|
+
const slugDir = join(kitsDir, slug)
|
|
114
|
+
if (!existsSync(slugDir) || slug === "index.json") continue
|
|
115
|
+
for (const version of readdirSync(slugDir)) {
|
|
116
|
+
const manifestPath = join(slugDir, version, "kit.json")
|
|
117
|
+
if (!existsSync(manifestPath)) continue
|
|
118
|
+
kits.push({ dir: join(slugDir, version), manifest: readJson(manifestPath) })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return kits
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function searchText(kit) {
|
|
125
|
+
const parts = [
|
|
126
|
+
kit.slug,
|
|
127
|
+
kit.name,
|
|
128
|
+
kit.kitType,
|
|
129
|
+
kit.category,
|
|
130
|
+
kit.description,
|
|
131
|
+
kit.useWhen,
|
|
132
|
+
kit.avoidWhen,
|
|
133
|
+
kit.userFacingSummary,
|
|
134
|
+
kit.agentInstructions,
|
|
135
|
+
...normalizeList(kit.appTypes),
|
|
136
|
+
...normalizeList(kit.screenTypes),
|
|
137
|
+
...normalizeList(kit.featureTags),
|
|
138
|
+
...normalizeList(kit.visualStyles),
|
|
139
|
+
...normalizeList(kit.provides),
|
|
140
|
+
...normalizeList(kit.requires),
|
|
141
|
+
...normalizeList(kit.dependencies),
|
|
142
|
+
]
|
|
143
|
+
return parts.filter(Boolean).join(" ").toLowerCase()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function matchesFilter(kit, filters = {}) {
|
|
147
|
+
if (!isObject(filters)) return true
|
|
148
|
+
if (filters.category && kit.category !== filters.category) return false
|
|
149
|
+
if (filters.maturity) {
|
|
150
|
+
const allowed = Array.isArray(filters.maturity) ? filters.maturity : [filters.maturity]
|
|
151
|
+
if (!allowed.includes(kit.maturity)) return false
|
|
152
|
+
}
|
|
153
|
+
const listChecks = [
|
|
154
|
+
["app_type", "appTypes"],
|
|
155
|
+
["screen_type", "screenTypes"],
|
|
156
|
+
["feature_tag", "featureTags"],
|
|
157
|
+
]
|
|
158
|
+
for (const [filterKey, kitKey] of listChecks) {
|
|
159
|
+
if (filters[filterKey] && !normalizeList(kit[kitKey]).includes(filters[filterKey])) return false
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(filters.requires)) {
|
|
162
|
+
const requires = normalizeList(kit.requires)
|
|
163
|
+
for (const required of filters.requires) {
|
|
164
|
+
if (!requires.includes(required)) return false
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function scoreKit(query, kit) {
|
|
171
|
+
const text = searchText(kit)
|
|
172
|
+
const terms = String(query || "")
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.split(/[^a-z0-9áéíóúñ]+/i)
|
|
175
|
+
.map((term) => term.trim())
|
|
176
|
+
.filter((term) => term.length >= 3)
|
|
177
|
+
if (!terms.length) return 0
|
|
178
|
+
let score = 0
|
|
179
|
+
for (const term of terms) {
|
|
180
|
+
if (kit.slug?.includes(term)) score += 5
|
|
181
|
+
if (kit.category?.toLowerCase() === term) score += 4
|
|
182
|
+
if (text.includes(term)) score += 1
|
|
183
|
+
}
|
|
184
|
+
return score
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function assetIndex(dir) {
|
|
188
|
+
const out = []
|
|
189
|
+
function visit(abs, rel = "") {
|
|
190
|
+
for (const name of readdirSync(abs, { withFileTypes: true })) {
|
|
191
|
+
const childAbs = join(abs, name.name)
|
|
192
|
+
const childRel = rel ? `${rel}/${name.name}` : name.name
|
|
193
|
+
if (name.isDirectory()) visit(childAbs, childRel)
|
|
194
|
+
else out.push({ logicalPath: childRel, sizeBytes: readFileSync(childAbs).length })
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
visit(dir)
|
|
198
|
+
return out.sort((a, b) => a.logicalPath.localeCompare(b.logicalPath))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stripFrontendPrefix(source) {
|
|
202
|
+
return source.replace(/^frontend\/src\//, "")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function defaultTargetForSource(manifest, source, kind) {
|
|
206
|
+
if (kind === "frontend") return `src/dypai-kits/${manifest.slug}/${stripFrontendPrefix(source)}`
|
|
207
|
+
if (kind === "backend") return `dypai/endpoints/${manifest.slug}/${source.split("/").pop()}`
|
|
208
|
+
if (kind === "database") return `dypai/kit-installations/${manifest.slug}/${manifest.version}/${source.split("/").pop()}`
|
|
209
|
+
return `.dypai/kits/${manifest.slug}/${source.split("/").pop()}`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function pluralize(word) {
|
|
213
|
+
if (!word) return word
|
|
214
|
+
if (word.endsWith("s")) return word
|
|
215
|
+
if (word.endsWith("y")) return `${word.slice(0, -1)}ies`
|
|
216
|
+
return `${word}s`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function namingContext(manifest, naming = {}) {
|
|
220
|
+
const adaptation = isObject(manifest.adaptation) ? manifest.adaptation : {}
|
|
221
|
+
const sourceEntity = adaptation.defaultEntity || normalizeList(adaptation.entityAliases)[0] || ""
|
|
222
|
+
const sourcePlural = adaptation.defaultEndpointPrefix || pluralize(sourceEntity)
|
|
223
|
+
const sourceTable = adaptation.defaultTableName || sourcePlural
|
|
224
|
+
const targetEntity = naming.entity || sourceEntity
|
|
225
|
+
const targetPlural = naming.endpointPrefix || pluralize(targetEntity)
|
|
226
|
+
const targetTable = naming.tableName || targetPlural
|
|
227
|
+
return { sourceEntity, sourcePlural, sourceTable, targetEntity, targetPlural, targetTable }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function replaceWholeWords(text, replacements) {
|
|
231
|
+
let out = text
|
|
232
|
+
for (const [from, to] of replacements) {
|
|
233
|
+
if (!from || !to || from === to) continue
|
|
234
|
+
out = out.replace(new RegExp(`\\b${from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "g"), to)
|
|
235
|
+
}
|
|
236
|
+
return out
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function adaptText(content, manifest, naming, kind) {
|
|
240
|
+
const n = namingContext(manifest, naming)
|
|
241
|
+
if (!n.sourceEntity && !n.sourcePlural && !n.sourceTable) return content
|
|
242
|
+
if (kind === "frontend") return content
|
|
243
|
+
|
|
244
|
+
if (kind === "backend") {
|
|
245
|
+
const withEndpointName = content.replace(/^name:\s*([a-z0-9-]+)\s*$/m, (_line, endpointName) => {
|
|
246
|
+
return `name: ${adaptTarget(endpointName, manifest, naming)}`
|
|
247
|
+
})
|
|
248
|
+
return replaceWholeWords(withEndpointName, [
|
|
249
|
+
[n.sourceTable, n.targetTable],
|
|
250
|
+
[n.sourceEntity, n.targetEntity],
|
|
251
|
+
])
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return replaceWholeWords(content, [
|
|
255
|
+
[n.sourceTable, n.targetTable],
|
|
256
|
+
[n.sourcePlural, n.targetTable],
|
|
257
|
+
[n.sourceEntity, n.targetEntity],
|
|
258
|
+
])
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function adaptTarget(target, manifest, naming) {
|
|
262
|
+
const n = namingContext(manifest, naming)
|
|
263
|
+
return replaceWholeWords(target, [
|
|
264
|
+
[n.sourcePlural, n.targetPlural],
|
|
265
|
+
[n.sourceEntity, n.targetEntity],
|
|
266
|
+
[n.sourceTable, n.targetTable],
|
|
267
|
+
])
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function installRecordPath(workspaceRoot) {
|
|
271
|
+
return join(workspaceRoot, ".dypai", "kits", "installed.json")
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function readInstallRecord(workspaceRoot) {
|
|
275
|
+
const path = installRecordPath(workspaceRoot)
|
|
276
|
+
if (!existsSync(path)) return { schemaVersion: 1, kits: [] }
|
|
277
|
+
try {
|
|
278
|
+
const parsed = readJson(path)
|
|
279
|
+
if (isObject(parsed) && Array.isArray(parsed.kits)) return parsed
|
|
280
|
+
} catch {}
|
|
281
|
+
return { schemaVersion: 1, kits: [] }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function writeInstallRecord(workspaceRoot, record) {
|
|
285
|
+
const path = installRecordPath(workspaceRoot)
|
|
286
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
287
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function ownedTargets(record, slug, version) {
|
|
291
|
+
const targets = new Set()
|
|
292
|
+
for (const item of record.kits || []) {
|
|
293
|
+
if (item.slug === slug && item.version === version) {
|
|
294
|
+
for (const file of item.files || []) {
|
|
295
|
+
if (file.target) targets.add(file.target)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return targets
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind, overwrite, record, copied, skipped }) {
|
|
303
|
+
const targetAbs = safeTarget(workspaceRoot, targetRel)
|
|
304
|
+
const targetExists = existsSync(targetAbs)
|
|
305
|
+
if (targetExists) {
|
|
306
|
+
if (overwrite === "fail") throw new Error(`Target already exists: ${targetRel}`)
|
|
307
|
+
if (overwrite !== "replace" || !ownedTargets(record, manifest.slug, manifest.version).has(targetRel)) {
|
|
308
|
+
skipped.push(targetRel)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
mkdirSync(dirname(targetAbs), { recursive: true })
|
|
314
|
+
const isText = /\.(tsx?|jsx?|ya?ml|json|md|sql|css|scss|html)$/i.test(sourceAbs)
|
|
315
|
+
if (isText) {
|
|
316
|
+
const content = adaptText(readFileSync(sourceAbs, "utf8"), manifest, naming, kind)
|
|
317
|
+
writeFileSync(targetAbs, content)
|
|
318
|
+
} else {
|
|
319
|
+
copyFileSync(sourceAbs, targetAbs)
|
|
320
|
+
}
|
|
321
|
+
copied.push(targetRel)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export const searchCapabilityKitsTool = {
|
|
325
|
+
name: "search_capability_kits",
|
|
326
|
+
description: "Search local DYPAI capability kits before building complex UI like calendars, maps, Kanban, upload flows, CRUD tables, dashboards, or editors.",
|
|
327
|
+
inputSchema: {
|
|
328
|
+
type: "object",
|
|
329
|
+
properties: {
|
|
330
|
+
query: { type: "string", description: "Feature need and app domain, e.g. booking calendar for hotel reservations." },
|
|
331
|
+
limit: { type: "integer", default: DEFAULT_LIMIT, minimum: 1, maximum: 10 },
|
|
332
|
+
filters: { type: "object", description: "Optional filters: category, maturity, app_type, screen_type, feature_tag, requires." },
|
|
333
|
+
kits_root: { type: "string", description: "Optional local path to dypai-capability-kits. Defaults to DYPAI_CAPABILITY_KITS_ROOT or sibling repo." },
|
|
334
|
+
},
|
|
335
|
+
required: ["query"],
|
|
336
|
+
},
|
|
337
|
+
async execute({ query, limit = DEFAULT_LIMIT, filters = {}, kits_root }) {
|
|
338
|
+
const root = resolveKitsRoot(kits_root)
|
|
339
|
+
const kits = listKits(root)
|
|
340
|
+
.filter(({ manifest }) => matchesFilter(manifest, filters))
|
|
341
|
+
.map(({ manifest }) => ({ manifest, score: scoreKit(query, manifest) }))
|
|
342
|
+
.filter((item) => item.score > 0 || !query)
|
|
343
|
+
.sort((a, b) => b.score - a.score)
|
|
344
|
+
.slice(0, Math.max(1, Math.min(Number(limit) || DEFAULT_LIMIT, 10)))
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
ok: true,
|
|
348
|
+
source: "local_repo",
|
|
349
|
+
kitsRoot: root,
|
|
350
|
+
count: kits.length,
|
|
351
|
+
kits: kits.map(({ manifest, score }) => ({
|
|
352
|
+
slug: manifest.slug,
|
|
353
|
+
version: manifest.version,
|
|
354
|
+
name: manifest.name,
|
|
355
|
+
category: manifest.category,
|
|
356
|
+
maturity: manifest.maturity,
|
|
357
|
+
description: manifest.description,
|
|
358
|
+
useWhen: manifest.useWhen,
|
|
359
|
+
provides: manifest.provides,
|
|
360
|
+
requires: manifest.requires,
|
|
361
|
+
dependencies: manifest.dependencies,
|
|
362
|
+
frontendManifest: manifest.frontendManifest,
|
|
363
|
+
backendManifest: manifest.backendManifest,
|
|
364
|
+
databaseManifest: manifest.databaseManifest,
|
|
365
|
+
score,
|
|
366
|
+
})),
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function inspectCapabilityKit({ slug, version = "1.0.0", kits_root }) {
|
|
372
|
+
const root = resolveKitsRoot(kits_root)
|
|
373
|
+
const { dir, manifest } = loadKit(root, slug, version)
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
operation: "inspect",
|
|
377
|
+
source: "local_repo",
|
|
378
|
+
kitsRoot: root,
|
|
379
|
+
kit: manifest,
|
|
380
|
+
assets: assetIndex(dir),
|
|
381
|
+
agentNotes: existsSync(join(dir, "agent.md")) ? readFileSync(join(dir, "agent.md"), "utf8") : undefined,
|
|
382
|
+
checklist: existsSync(join(dir, "verification", "checklist.md"))
|
|
383
|
+
? readFileSync(join(dir, "verification", "checklist.md"), "utf8")
|
|
384
|
+
: undefined,
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function applyCapabilityKit({
|
|
389
|
+
slug,
|
|
390
|
+
version = "1.0.0",
|
|
391
|
+
targetFeature = "",
|
|
392
|
+
install = {},
|
|
393
|
+
naming = {},
|
|
394
|
+
target = {},
|
|
395
|
+
overwrite = "skip",
|
|
396
|
+
workspace_root,
|
|
397
|
+
kits_root,
|
|
398
|
+
}) {
|
|
399
|
+
const root = resolveKitsRoot(kits_root)
|
|
400
|
+
const workspaceRoot = resolveWorkspaceRoot(workspace_root)
|
|
401
|
+
const { dir, manifest } = loadKit(root, slug, version)
|
|
402
|
+
const record = readInstallRecord(workspaceRoot)
|
|
403
|
+
const previousEntries = (record.kits || []).filter((item) => item.slug === manifest.slug && item.version === manifest.version)
|
|
404
|
+
const copied = []
|
|
405
|
+
const skipped = []
|
|
406
|
+
|
|
407
|
+
const installFrontend = install.frontend !== false
|
|
408
|
+
const installBackend = install.backend !== false
|
|
409
|
+
const installDatabase = install.database !== false
|
|
410
|
+
|
|
411
|
+
if (installFrontend) {
|
|
412
|
+
for (const asset of manifest.frontendManifest || []) {
|
|
413
|
+
const sourceAbs = join(dir, asset.source)
|
|
414
|
+
if (!existsSync(sourceAbs)) throw new Error(`Missing kit asset: ${asset.source}`)
|
|
415
|
+
let targetRel = asset.suggestedTarget || defaultTargetForSource(manifest, asset.source, "frontend")
|
|
416
|
+
if (target.frontendDir) {
|
|
417
|
+
targetRel = join(target.frontendDir, stripFrontendPrefix(asset.source)).split(sep).join("/")
|
|
418
|
+
}
|
|
419
|
+
writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "frontend", overwrite, record, copied, skipped })
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (installBackend) {
|
|
424
|
+
for (const asset of manifest.backendManifest || []) {
|
|
425
|
+
const sourceAbs = join(dir, asset.source)
|
|
426
|
+
if (!existsSync(sourceAbs)) throw new Error(`Missing kit asset: ${asset.source}`)
|
|
427
|
+
let targetRel = asset.suggestedTarget || defaultTargetForSource(manifest, asset.source, "backend")
|
|
428
|
+
targetRel = join(dirname(targetRel), adaptTarget(basename(targetRel), manifest, naming)).split(sep).join("/")
|
|
429
|
+
if (target.endpointDir) {
|
|
430
|
+
targetRel = join(target.endpointDir, adaptTarget(asset.source.split("/").pop(), manifest, naming)).split(sep).join("/")
|
|
431
|
+
}
|
|
432
|
+
writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "backend", overwrite, record, copied, skipped })
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (installDatabase && isObject(manifest.databaseManifest)) {
|
|
437
|
+
for (const key of ["schema", "seed"]) {
|
|
438
|
+
const source = manifest.databaseManifest[key]
|
|
439
|
+
if (!source) continue
|
|
440
|
+
const sourceAbs = join(dir, source)
|
|
441
|
+
if (!existsSync(sourceAbs)) throw new Error(`Missing kit asset: ${source}`)
|
|
442
|
+
let targetRel = defaultTargetForSource(manifest, source, "database")
|
|
443
|
+
if (target.databaseDir) {
|
|
444
|
+
targetRel = join(target.databaseDir, source.split("/").pop()).split(sep).join("/")
|
|
445
|
+
}
|
|
446
|
+
writeTemplateFile({ sourceAbs, targetRel, workspaceRoot, manifest, naming, kind: "database", overwrite, record, copied, skipped })
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const imported = (manifest.frontendManifest || []).map((asset) => ({
|
|
451
|
+
name: asset.exportName,
|
|
452
|
+
from: target.frontendDir
|
|
453
|
+
? `@/${join(target.frontendDir, stripFrontendPrefix(asset.source)).replace(/^src\//, "").replace(/\\/g, "/").replace(/\.(tsx?|jsx?)$/, "")}`
|
|
454
|
+
: asset.importPath,
|
|
455
|
+
})).filter((item) => item.name && item.from)
|
|
456
|
+
|
|
457
|
+
const backendEndpoints = (manifest.backendManifest || []).map((asset) =>
|
|
458
|
+
adaptTarget(asset.endpointName || asset.source.split("/").pop().replace(/\.ya?ml$/, ""), manifest, naming),
|
|
459
|
+
)
|
|
460
|
+
const databaseTables = normalizeList(manifest.databaseManifest?.tables).map((table) =>
|
|
461
|
+
adaptTarget(table, manifest, naming),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
const previousFiles = previousEntries.flatMap((item) => Array.isArray(item.files) ? item.files : [])
|
|
465
|
+
const fileMap = new Map()
|
|
466
|
+
for (const file of previousFiles) {
|
|
467
|
+
if (file?.target) fileMap.set(file.target, file)
|
|
468
|
+
}
|
|
469
|
+
for (const targetPath of copied) {
|
|
470
|
+
fileMap.set(targetPath, { target: targetPath })
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const entry = {
|
|
474
|
+
slug: manifest.slug,
|
|
475
|
+
version: manifest.version,
|
|
476
|
+
installedAt: new Date().toISOString(),
|
|
477
|
+
targetFeature,
|
|
478
|
+
files: [...fileMap.values()],
|
|
479
|
+
skippedFiles: skipped,
|
|
480
|
+
naming,
|
|
481
|
+
}
|
|
482
|
+
record.kits = (record.kits || []).filter((item) => !(item.slug === manifest.slug && item.version === manifest.version))
|
|
483
|
+
record.kits.push(entry)
|
|
484
|
+
writeInstallRecord(workspaceRoot, record)
|
|
485
|
+
|
|
486
|
+
const dependencies = manifest.dependencies || []
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
ok: true,
|
|
490
|
+
operation: "apply",
|
|
491
|
+
slug: manifest.slug,
|
|
492
|
+
version: manifest.version,
|
|
493
|
+
workspaceRoot,
|
|
494
|
+
installed: {
|
|
495
|
+
files: copied,
|
|
496
|
+
skipped,
|
|
497
|
+
recordFile: ".dypai/kits/installed.json",
|
|
498
|
+
},
|
|
499
|
+
imports: imported,
|
|
500
|
+
backend: {
|
|
501
|
+
endpoints: backendEndpoints,
|
|
502
|
+
requiresPublish: Boolean(manifest.install?.requiresBackendPublish || backendEndpoints.length),
|
|
503
|
+
},
|
|
504
|
+
database: {
|
|
505
|
+
tables: databaseTables,
|
|
506
|
+
requiresExecuteSql: Boolean(manifest.install?.requiresExecuteSql || databaseTables.length),
|
|
507
|
+
},
|
|
508
|
+
dependencies,
|
|
509
|
+
nextSteps: [
|
|
510
|
+
...(dependencies.length
|
|
511
|
+
? [`Add any missing package dependencies to the target workspace package.json, then install them locally before building: ${dependencies.join(", ")}.`]
|
|
512
|
+
: []),
|
|
513
|
+
"Wire installed frontend components into the selected app page or route.",
|
|
514
|
+
...(databaseTables.length ? ["Apply the installed schema SQL with execute_sql if the tables do not already exist."] : []),
|
|
515
|
+
...(backendEndpoints.length ? ["Run backend validation and endpoint tests for installed/renamed endpoints."] : []),
|
|
516
|
+
"Run frontend verification after wiring imports and data callbacks.",
|
|
517
|
+
],
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export const manageCapabilityKitTool = {
|
|
522
|
+
name: "manage_capability_kit",
|
|
523
|
+
description: "Inspect or install a local DYPAI capability kit. Use operation='inspect' after search when you need full manifest details, then operation='apply' to copy editable source into the workspace. Apply never executes SQL, publishes backend, deploys frontend, or installs npm packages.",
|
|
524
|
+
inputSchema: {
|
|
525
|
+
type: "object",
|
|
526
|
+
properties: {
|
|
527
|
+
operation: { type: "string", enum: ["inspect", "apply"], description: "inspect returns manifest/assets. apply copies kit files into the workspace." },
|
|
528
|
+
slug: { type: "string" },
|
|
529
|
+
version: { type: "string", default: "1.0.0" },
|
|
530
|
+
targetFeature: { type: "string", description: "Domain/feature being built, e.g. hotel reservations." },
|
|
531
|
+
install: {
|
|
532
|
+
type: "object",
|
|
533
|
+
properties: {
|
|
534
|
+
frontend: { type: "boolean", default: true },
|
|
535
|
+
backend: { type: "boolean", default: true },
|
|
536
|
+
database: { type: "boolean", default: true },
|
|
537
|
+
examples: { type: "boolean", default: false },
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
naming: {
|
|
541
|
+
type: "object",
|
|
542
|
+
properties: {
|
|
543
|
+
entity: { type: "string", description: "Singular domain entity, e.g. reservation." },
|
|
544
|
+
endpointPrefix: { type: "string", description: "Plural endpoint/table-friendly domain name, e.g. reservations." },
|
|
545
|
+
tableName: { type: "string", description: "Database table name, e.g. reservations." },
|
|
546
|
+
},
|
|
547
|
+
},
|
|
548
|
+
target: {
|
|
549
|
+
type: "object",
|
|
550
|
+
properties: {
|
|
551
|
+
frontendDir: { type: "string" },
|
|
552
|
+
endpointDir: { type: "string" },
|
|
553
|
+
databaseDir: { type: "string" },
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
overwrite: { type: "string", enum: ["skip", "fail", "replace"], default: "skip" },
|
|
557
|
+
workspace_root: { type: "string", description: "Optional absolute path to the target app workspace." },
|
|
558
|
+
kits_root: { type: "string", description: "Optional local path to dypai-capability-kits." },
|
|
559
|
+
},
|
|
560
|
+
required: ["operation", "slug"],
|
|
561
|
+
},
|
|
562
|
+
async execute(args) {
|
|
563
|
+
if (args?.operation === "inspect") return inspectCapabilityKit(args)
|
|
564
|
+
if (args?.operation === "apply") return applyCapabilityKit(args)
|
|
565
|
+
throw new Error("manage_capability_kit requires operation 'inspect' or 'apply'.")
|
|
566
|
+
},
|
|
567
|
+
}
|
|
@@ -14,6 +14,8 @@ import YAML from "yaml"
|
|
|
14
14
|
import { proxyToolCall } from "../proxy.js"
|
|
15
15
|
import { deserializeEndpoint, serializeEndpoint } from "./codec.js"
|
|
16
16
|
|
|
17
|
+
const ENDPOINT_NAME_RE = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/
|
|
18
|
+
|
|
17
19
|
// ─── Remote ────────────────────────────────────────────────────────────────
|
|
18
20
|
|
|
19
21
|
async function execSql(projectId, sql) {
|
|
@@ -198,6 +200,76 @@ async function findEndpointYamls(endpointsDir) {
|
|
|
198
200
|
return out
|
|
199
201
|
}
|
|
200
202
|
|
|
203
|
+
function endpointFileSlug(rel) {
|
|
204
|
+
const filename = rel.split("/").pop() || ""
|
|
205
|
+
return filename.replace(/\.ya?ml$/i, "")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function validateEndpointNameContract(rel, doc) {
|
|
209
|
+
const expectedName = endpointFileSlug(rel)
|
|
210
|
+
if (!doc || typeof doc !== "object") {
|
|
211
|
+
return {
|
|
212
|
+
rule: "endpoint_yaml_root_invalid",
|
|
213
|
+
loc: "root",
|
|
214
|
+
error: "endpoint YAML must parse to an object",
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (typeof doc.name !== "string") {
|
|
219
|
+
return {
|
|
220
|
+
rule: "endpoint_missing_name",
|
|
221
|
+
loc: "name",
|
|
222
|
+
error: `endpoint name must be a string and must match the file basename '${expectedName}'`,
|
|
223
|
+
fix_hint: `Set name: ${expectedName}. The endpoint name is the public API slug and must match the file basename.`,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const name = doc.name.trim()
|
|
228
|
+
if (!name) {
|
|
229
|
+
return {
|
|
230
|
+
rule: "endpoint_missing_name",
|
|
231
|
+
loc: "name",
|
|
232
|
+
error: `endpoint is missing name; use name: ${expectedName}`,
|
|
233
|
+
fix_hint: `Set name: ${expectedName}.`,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (name !== doc.name || !ENDPOINT_NAME_RE.test(name)) {
|
|
237
|
+
return {
|
|
238
|
+
rule: "endpoint_invalid_name_slug",
|
|
239
|
+
loc: "name",
|
|
240
|
+
error: `endpoint name '${doc.name}' is not a valid API slug; use lowercase letters, numbers, hyphens, or underscores only, with no spaces`,
|
|
241
|
+
fix_hint: "Do not put human titles in `name`; use `description` for labels.",
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (name !== expectedName) {
|
|
245
|
+
return {
|
|
246
|
+
rule: "endpoint_name_file_mismatch",
|
|
247
|
+
loc: "name",
|
|
248
|
+
error: `endpoint name '${name}' must match file basename '${expectedName}'`,
|
|
249
|
+
fix_hint: `Use name: ${expectedName}, or rename the file so both match.`,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (doc.id != null) {
|
|
253
|
+
if (typeof doc.id !== "string" || doc.id.trim() !== doc.id || !doc.id.trim()) {
|
|
254
|
+
return {
|
|
255
|
+
rule: "endpoint_invalid_id",
|
|
256
|
+
loc: "id",
|
|
257
|
+
error: "endpoint id must be a non-empty string without surrounding spaces when present",
|
|
258
|
+
fix_hint: "Prefer omitting `id` in endpoint YAML. If present, it must exactly match `name`.",
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (doc.id !== name) {
|
|
262
|
+
return {
|
|
263
|
+
rule: "endpoint_id_name_mismatch",
|
|
264
|
+
loc: "id",
|
|
265
|
+
error: `endpoint id '${doc.id}' must match name '${name}'`,
|
|
266
|
+
fix_hint: "Prefer omitting `id` in endpoint YAML. If present, keep id, name, and file basename identical.",
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
|
|
201
273
|
export async function readLocalState(rootDir) {
|
|
202
274
|
const endpointsDir = join(rootDir, "endpoints")
|
|
203
275
|
const yamls = await findEndpointYamls(endpointsDir)
|
|
@@ -209,8 +281,9 @@ export async function readLocalState(rootDir) {
|
|
|
209
281
|
try {
|
|
210
282
|
const raw = await readFile(join(endpointsDir, rel), "utf8")
|
|
211
283
|
const doc = YAML.parse(raw)
|
|
212
|
-
|
|
213
|
-
|
|
284
|
+
const nameError = validateEndpointNameContract(rel, doc)
|
|
285
|
+
if (nameError) {
|
|
286
|
+
errors.push({ file: rel, ...nameError })
|
|
214
287
|
continue
|
|
215
288
|
}
|
|
216
289
|
|
|
@@ -227,7 +300,7 @@ export async function readLocalState(rootDir) {
|
|
|
227
300
|
)
|
|
228
301
|
const fileMap = Object.fromEntries(refs.map((p, i) => [p, contents[i]]))
|
|
229
302
|
|
|
230
|
-
byName[doc.name] = { doc, fileMap }
|
|
303
|
+
byName[doc.name] = { doc, fileMap, file: rel, group }
|
|
231
304
|
} catch (e) {
|
|
232
305
|
errors.push({ file: rel, error: e.message })
|
|
233
306
|
}
|
|
@@ -235,24 +235,29 @@ function isRawCodePlaceholderSource(source, loc) {
|
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/**
|
|
238
|
-
* Extract the first
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* "input.limit | default(100)" → "input.limit"
|
|
242
|
-
* "input.page ?? 1" → "input.page"
|
|
243
|
-
* "nodes.foo.bar || 'x'" → "nodes.foo.bar"
|
|
244
|
-
* "current_user_id | trim" → "current_user_id"
|
|
245
|
-
* Returns the trimmed/cleaned head (NOT just the leaf identifier — keeps
|
|
246
|
-
* dotted paths intact so callers that split on '.' still work).
|
|
238
|
+
* Extract the first runtime path from an already-validated placeholder.
|
|
239
|
+
* Returns the trimmed/cleaned head (NOT just the leaf identifier), keeping
|
|
240
|
+
* dotted paths intact so callers that split on '.' still work.
|
|
247
241
|
*/
|
|
248
242
|
function stripExprTail(expr) {
|
|
249
|
-
// Cut at the first character that can't be part of a path/identifier
|
|
250
|
-
//
|
|
251
|
-
//
|
|
243
|
+
// Cut at the first character that can't be part of a path/identifier.
|
|
244
|
+
// Bracket access (e.g. items[0]) is preserved; callers split on '[' if
|
|
245
|
+
// they need to.
|
|
252
246
|
const m = /^[\s]*([A-Za-z_$][A-Za-z_$0-9.\[\]]*)/.exec(expr)
|
|
253
247
|
return m ? m[1] : expr.trim()
|
|
254
248
|
}
|
|
255
249
|
|
|
250
|
+
const RUNTIME_PLACEHOLDER_PATH_RE = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_-]*|\[[0-9]+\])*$/
|
|
251
|
+
const RUNTIME_PLACEHOLDER_OPERATOR_RE = /(\|\||&&|\?\?|[?:+*/=<>!()]|\s+\b(?:or|and)\b\s+)/i
|
|
252
|
+
|
|
253
|
+
function unsupportedRuntimePlaceholder(expr) {
|
|
254
|
+
const clean = expr.trim()
|
|
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
|
|
259
|
+
}
|
|
260
|
+
|
|
256
261
|
/** Minimal Levenshtein distance, caps at 3 for "did you mean" typo suggestions. */
|
|
257
262
|
function levenshteinSmall(a, b) {
|
|
258
263
|
if (a === b) return 0
|
|
@@ -285,6 +290,31 @@ async function readSchemaTables(rootDir) {
|
|
|
285
290
|
}
|
|
286
291
|
}
|
|
287
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
|
+
|
|
288
318
|
/** Extract referenced table names from a SQL string: `FROM public.X`, `JOIN public.X`, `INTO public.X`, `UPDATE public.X`. */
|
|
289
319
|
function extractSqlTables(sql) {
|
|
290
320
|
const tables = new Set()
|
|
@@ -351,6 +381,288 @@ function lookupFileMapContent(fileMap, ref) {
|
|
|
351
381
|
return fileMap[key] || fileMap[key.replace(/^dypai\//, "")] || fileMap[`dypai/${key}`] || ""
|
|
352
382
|
}
|
|
353
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 findOuterSelectList(sqlText) {
|
|
401
|
+
const sql = stripSqlForInference(sqlText)
|
|
402
|
+
if (!sql) return null
|
|
403
|
+
let depth = 0
|
|
404
|
+
let selectStart = -1
|
|
405
|
+
for (let i = 0; i < sql.length; i++) {
|
|
406
|
+
const ch = sql[i]
|
|
407
|
+
if (ch === "(") depth++
|
|
408
|
+
else if (ch === ")") depth = Math.max(0, depth - 1)
|
|
409
|
+
|
|
410
|
+
if (depth !== 0) continue
|
|
411
|
+
if (selectStart < 0 && isWordAt(sql, i, "select")) {
|
|
412
|
+
selectStart = i + "select".length
|
|
413
|
+
i += "select".length - 1
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
if (selectStart >= 0 && isWordAt(sql, i, "from")) {
|
|
417
|
+
return sql.slice(selectStart, i).trim()
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return null
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function splitTopLevelComma(list) {
|
|
424
|
+
const parts = []
|
|
425
|
+
let depth = 0
|
|
426
|
+
let start = 0
|
|
427
|
+
for (let i = 0; i < list.length; i++) {
|
|
428
|
+
const ch = list[i]
|
|
429
|
+
if (ch === "(") depth++
|
|
430
|
+
else if (ch === ")") depth = Math.max(0, depth - 1)
|
|
431
|
+
else if (ch === "," && depth === 0) {
|
|
432
|
+
parts.push(list.slice(start, i).trim())
|
|
433
|
+
start = i + 1
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const tail = list.slice(start).trim()
|
|
437
|
+
if (tail) parts.push(tail)
|
|
438
|
+
return parts
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function inferSelectOutputColumns(sqlText) {
|
|
442
|
+
const selectList = findOuterSelectList(sqlText)
|
|
443
|
+
if (!selectList) return { properties: null, strict: false }
|
|
444
|
+
|
|
445
|
+
const props = new Set()
|
|
446
|
+
let strict = true
|
|
447
|
+
for (const itemRaw of splitTopLevelComma(selectList)) {
|
|
448
|
+
const item = itemRaw.trim()
|
|
449
|
+
if (!item || item === "*" || /\.\*$/.test(item)) {
|
|
450
|
+
strict = false
|
|
451
|
+
continue
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const asAlias = /\bas\s+"?([A-Za-z_]\w*)"?$/i.exec(item)?.[1]
|
|
455
|
+
if (asAlias) {
|
|
456
|
+
props.add(asAlias)
|
|
457
|
+
continue
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const simpleColumn = /^(?:(?:"?[A-Za-z_]\w*"?\.)?)"?([A-Za-z_]\w*)"?$/.exec(item)?.[1]
|
|
461
|
+
if (simpleColumn) {
|
|
462
|
+
props.add(simpleColumn)
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const trailingAlias = /\s+"?([A-Za-z_]\w*)"?$/.exec(item)?.[1]
|
|
467
|
+
if (trailingAlias && !SQL_KEYWORDS_AFTER_FROM.has(trailingAlias.toUpperCase())) {
|
|
468
|
+
props.add(trailingAlias)
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
strict = false
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return { properties: props.size > 0 ? props : null, strict }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function inferSqlCardinality(sqlText) {
|
|
479
|
+
const sql = stripSqlForInference(sqlText)
|
|
480
|
+
if (!sql) return null
|
|
481
|
+
if (/\blimit\s+1(?:\D|$)/i.test(sql)) return "single"
|
|
482
|
+
const startsLikeRead = /^(select|with)\b/i.test(sql)
|
|
483
|
+
const hasAggregate = /\b(count|sum|avg|min|max|json_agg|jsonb_agg|array_agg|string_agg)\s*\(/i.test(sql)
|
|
484
|
+
const hasGroupBy = /\bgroup\s+by\b/i.test(sql)
|
|
485
|
+
if (startsLikeRead && hasAggregate && !hasGroupBy) return "single"
|
|
486
|
+
return "many"
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function returningColumns(returning, table, schemaColumns) {
|
|
490
|
+
if (Array.isArray(returning)) return new Set(returning.filter(v => typeof v === "string" && v !== "*"))
|
|
491
|
+
if (typeof returning === "string" && returning.trim() && returning.trim() !== "*") {
|
|
492
|
+
return new Set(returning.split(",").map(s => s.trim()).filter(Boolean))
|
|
493
|
+
}
|
|
494
|
+
if (table && schemaColumns?.[table]?.size) return new Set(schemaColumns[table])
|
|
495
|
+
return null
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function buildNodeOutputContracts(nodes, fileMap, ctx) {
|
|
499
|
+
const contracts = new Map()
|
|
500
|
+
for (const node of nodes || []) {
|
|
501
|
+
if (!node || typeof node !== "object" || !node.id) continue
|
|
502
|
+
const nodeType = node.type ?? node.node_type
|
|
503
|
+
const params = nodeParams(node)
|
|
504
|
+
|
|
505
|
+
if (nodeType === "set_fields") {
|
|
506
|
+
const op = nodeField(node, params, "operation") || "set"
|
|
507
|
+
const fields = nodeField(node, params, "fields")
|
|
508
|
+
if (fields && typeof fields === "object" && !Array.isArray(fields)) {
|
|
509
|
+
contracts.set(node.id, {
|
|
510
|
+
type: "object",
|
|
511
|
+
properties: new Set(Object.keys(fields).map(k => k.split(".")[0])),
|
|
512
|
+
strict: op === "compose",
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
continue
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (nodeType !== "dypai_database") continue
|
|
519
|
+
const op = nodeField(node, params, "operation")
|
|
520
|
+
const table = nodeField(node, params, "table") ?? nodeField(node, params, "table_name")
|
|
521
|
+
|
|
522
|
+
if (op === "query" || op === "custom_query") {
|
|
523
|
+
const query = nodeField(node, params, "query") || lookupFileMapContent(fileMap, nodeField(node, params, "query_file"))
|
|
524
|
+
const inferred = inferSelectOutputColumns(query)
|
|
525
|
+
contracts.set(node.id, {
|
|
526
|
+
type: "array",
|
|
527
|
+
cardinality: inferSqlCardinality(query),
|
|
528
|
+
properties: inferred.properties,
|
|
529
|
+
strict: inferred.strict,
|
|
530
|
+
})
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (op === "select") {
|
|
535
|
+
contracts.set(node.id, {
|
|
536
|
+
type: "array",
|
|
537
|
+
cardinality: "many",
|
|
538
|
+
properties: table ? ctx.schemaColumns?.[table] ?? null : null,
|
|
539
|
+
strict: Boolean(table && ctx.schemaColumns?.[table]),
|
|
540
|
+
})
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (op === "aggregate") {
|
|
545
|
+
contracts.set(node.id, { type: "object", properties: new Set(["value"]), strict: true })
|
|
546
|
+
continue
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (op === "insert") {
|
|
550
|
+
const bulk = nodeField(node, params, "mode") === "bulk" || Array.isArray(nodeField(node, params, "data"))
|
|
551
|
+
contracts.set(node.id, {
|
|
552
|
+
type: bulk ? "array" : "object",
|
|
553
|
+
cardinality: bulk ? "many" : "single",
|
|
554
|
+
properties: table ? ctx.schemaColumns?.[table] ?? null : null,
|
|
555
|
+
strict: Boolean(table && ctx.schemaColumns?.[table]),
|
|
556
|
+
})
|
|
557
|
+
continue
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (op === "upsert") {
|
|
561
|
+
contracts.set(node.id, {
|
|
562
|
+
type: "object",
|
|
563
|
+
cardinality: "single",
|
|
564
|
+
properties: table ? ctx.schemaColumns?.[table] ?? null : null,
|
|
565
|
+
strict: Boolean(table && ctx.schemaColumns?.[table]),
|
|
566
|
+
})
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (op === "update" || op === "delete" || op === "copy_to") {
|
|
571
|
+
contracts.set(node.id, {
|
|
572
|
+
type: "array",
|
|
573
|
+
cardinality: "many",
|
|
574
|
+
properties: table ? ctx.schemaColumns?.[table] ?? null : null,
|
|
575
|
+
strict: Boolean(table && ctx.schemaColumns?.[table]),
|
|
576
|
+
})
|
|
577
|
+
continue
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (op === "mutation") {
|
|
581
|
+
const insertValue = nodeField(node, params, "insert")
|
|
582
|
+
const updateValue = nodeField(node, params, "update")
|
|
583
|
+
const deleteValue = nodeField(node, params, "delete")
|
|
584
|
+
const returning = nodeField(node, params, "returning") ?? "*"
|
|
585
|
+
const props = returningColumns(returning, table, ctx.schemaColumns)
|
|
586
|
+
if (insertValue !== undefined && insertValue !== null) {
|
|
587
|
+
const bulk = nodeField(node, params, "mode") === "bulk" || Array.isArray(insertValue)
|
|
588
|
+
const hasConflict = nodeField(node, params, "on_conflict") !== undefined
|
|
589
|
+
contracts.set(node.id, {
|
|
590
|
+
type: bulk || hasConflict ? "array" : "object",
|
|
591
|
+
cardinality: bulk ? "many" : hasConflict ? "zero_or_one" : "single",
|
|
592
|
+
properties: props,
|
|
593
|
+
strict: Boolean(props),
|
|
594
|
+
})
|
|
595
|
+
} else if (updateValue !== undefined || deleteValue === true) {
|
|
596
|
+
contracts.set(node.id, {
|
|
597
|
+
type: "array",
|
|
598
|
+
cardinality: "many",
|
|
599
|
+
properties: props,
|
|
600
|
+
strict: Boolean(props),
|
|
601
|
+
})
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return contracts
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function extractRuntimePaths(expr) {
|
|
609
|
+
const clean = String(expr || "").replace(/'(?:[^']|'')*'|"(?:[^"\\]|\\.)*"/g, " ")
|
|
610
|
+
const paths = []
|
|
611
|
+
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
|
|
612
|
+
let m
|
|
613
|
+
while ((m = re.exec(clean)) !== null) paths.push(m[0])
|
|
614
|
+
if (paths.length === 0 && RUNTIME_PLACEHOLDER_PATH_RE.test(expr.trim())) paths.push(expr.trim())
|
|
615
|
+
return [...new Set(paths)]
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function parseNodeOutputRef(path) {
|
|
619
|
+
const m = /^nodes\.([A-Za-z_][A-Za-z0-9_-]*)(.*)$/.exec(path)
|
|
620
|
+
if (!m) return null
|
|
621
|
+
const nodeId = m[1]
|
|
622
|
+
let rest = m[2] || ""
|
|
623
|
+
let indexed = false
|
|
624
|
+
if (rest.startsWith("[")) {
|
|
625
|
+
const idx = /^\[[0-9]+\]/.exec(rest)?.[0]
|
|
626
|
+
if (idx) {
|
|
627
|
+
indexed = true
|
|
628
|
+
rest = rest.slice(idx.length)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const prop = rest.startsWith(".") ? /^\.([A-Za-z_][A-Za-z0-9_-]*)/.exec(rest)?.[1] : null
|
|
632
|
+
return { nodeId, indexed, prop }
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function validateNodeOutputRef({ expr, path, contract, endpoint, file, loc }) {
|
|
636
|
+
const ref = parseNodeOutputRef(path)
|
|
637
|
+
if (!ref || !ref.prop || !contract) return null
|
|
638
|
+
|
|
639
|
+
if (contract.strict && contract.properties && !contract.properties.has(ref.prop)) {
|
|
640
|
+
const known = [...contract.properties]
|
|
641
|
+
const suggestions = known.filter(k => levenshteinSmall(k, ref.prop) <= 2).slice(0, 3)
|
|
642
|
+
return {
|
|
643
|
+
severity: "warn",
|
|
644
|
+
rule: "node_output_property_unknown",
|
|
645
|
+
endpoint, file, loc,
|
|
646
|
+
message: `\${${expr}} references '${ref.prop}', but node '${ref.nodeId}' is inferred to output: ${known.join(", ") || "(no known properties)"}.`,
|
|
647
|
+
fix_hint: suggestions.length
|
|
648
|
+
? `Did you mean: ${suggestions.join(", ")}?`
|
|
649
|
+
: `Use one of: ${known.join(", ") || "(none)"}. If this is a dynamic SQL shape, verify with dypai_test_endpoint.`,
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (contract.type === "array" && contract.cardinality === "many" && !ref.indexed) {
|
|
654
|
+
return {
|
|
655
|
+
severity: "warn",
|
|
656
|
+
rule: "node_output_many_direct_property",
|
|
657
|
+
endpoint, file, loc,
|
|
658
|
+
message: `\${${expr}} reads property '${ref.prop}' directly from node '${ref.nodeId}', but that node is inferred to return many rows.`,
|
|
659
|
+
fix_hint: `Use \${nodes.${ref.nodeId}} for the full array, or \${nodes.${ref.nodeId}[0].${ref.prop}} if you intentionally want the first row.`,
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return null
|
|
664
|
+
}
|
|
665
|
+
|
|
354
666
|
function collectMissingTableCandidates(ctx, referencedTables, endpoint, file) {
|
|
355
667
|
if (!ctx.schemaTables) return
|
|
356
668
|
for (const table of referencedTables) {
|
|
@@ -375,6 +687,7 @@ function validateEndpoint(entry, ctx) {
|
|
|
375
687
|
|
|
376
688
|
const inputProps = doc.input?.properties || {}
|
|
377
689
|
const nodeIds = new Set((doc.workflow?.nodes || []).map(n => n.id))
|
|
690
|
+
const outputContracts = buildNodeOutputContracts(doc.workflow?.nodes || [], fileMap, ctx)
|
|
378
691
|
|
|
379
692
|
const jwt = ruleUsesJwt(doc.trigger)
|
|
380
693
|
|
|
@@ -407,12 +720,25 @@ function validateEndpoint(entry, ctx) {
|
|
|
407
720
|
for (const { source, loc, value } of sources) {
|
|
408
721
|
// --- Placeholder checks ---
|
|
409
722
|
for (const expr of extractPlaceholders(value)) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
723
|
+
const runtimePaths = extractRuntimePaths(expr)
|
|
724
|
+
if (unsupportedRuntimePlaceholder(expr)) {
|
|
725
|
+
diagnostics.push({
|
|
726
|
+
severity: "error",
|
|
727
|
+
rule: "unsupported_placeholder_expression",
|
|
728
|
+
endpoint: name, file, loc,
|
|
729
|
+
message: `\${${expr}} is not a simple runtime path the engine can resolve.`,
|
|
730
|
+
fix_hint:
|
|
731
|
+
`DYPAI placeholders are not JavaScript expressions. Use a single path like ` +
|
|
732
|
+
`\${input.file_path}, \${nodes.upload.storage_path}, or \${current_user_id}; ` +
|
|
733
|
+
`put fallback logic in the caller or split it into workflow nodes.`,
|
|
734
|
+
})
|
|
735
|
+
continue
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Normalize every runtime path we can find in this placeholder. Expressions
|
|
739
|
+
// such as `${nodes.x.stock >= input.qty}` are valid at runtime, so validate
|
|
740
|
+
// each referenced path instead of rejecting the whole expression.
|
|
741
|
+
for (const e of runtimePaths) {
|
|
416
742
|
|
|
417
743
|
// ${input.X} or ${input.X.Y}
|
|
418
744
|
// Only validate against the input schema if one is declared; DYPAI allows
|
|
@@ -448,6 +774,16 @@ function validateEndpoint(entry, ctx) {
|
|
|
448
774
|
if (!missingNodeRefs.has(nodeId)) {
|
|
449
775
|
missingNodeRefs.set(nodeId, { loc, expr })
|
|
450
776
|
}
|
|
777
|
+
} else {
|
|
778
|
+
const outputDiag = validateNodeOutputRef({
|
|
779
|
+
expr,
|
|
780
|
+
path: e,
|
|
781
|
+
contract: outputContracts.get(nodeId),
|
|
782
|
+
endpoint: name,
|
|
783
|
+
file,
|
|
784
|
+
loc,
|
|
785
|
+
})
|
|
786
|
+
if (outputDiag) diagnostics.push(outputDiag)
|
|
451
787
|
}
|
|
452
788
|
}
|
|
453
789
|
|
|
@@ -463,6 +799,7 @@ function validateEndpoint(entry, ctx) {
|
|
|
463
799
|
})
|
|
464
800
|
}
|
|
465
801
|
}
|
|
802
|
+
}
|
|
466
803
|
}
|
|
467
804
|
|
|
468
805
|
// NOTE: SQL table extraction used to live here (anywhere a string looked
|
|
@@ -1227,10 +1564,11 @@ export async function runValidation(rootDir, projectId) {
|
|
|
1227
1564
|
const config = await readLocalConfig(rootDir)
|
|
1228
1565
|
const targetProjectId = projectId || config?.project_id || null
|
|
1229
1566
|
|
|
1230
|
-
const [local, remote, schemaTables, nodeCatalog] = await Promise.all([
|
|
1567
|
+
const [local, remote, schemaTables, schemaColumns, nodeCatalog] = await Promise.all([
|
|
1231
1568
|
readLocalState(rootDir),
|
|
1232
1569
|
fetchRemoteState(targetProjectId),
|
|
1233
1570
|
readSchemaTables(rootDir),
|
|
1571
|
+
readSchemaColumns(rootDir),
|
|
1234
1572
|
loadNodeCatalog(rootDir),
|
|
1235
1573
|
])
|
|
1236
1574
|
|
|
@@ -1247,9 +1585,10 @@ export async function runValidation(rootDir, projectId) {
|
|
|
1247
1585
|
remoteCredentials,
|
|
1248
1586
|
toolEndpoints,
|
|
1249
1587
|
schemaTables,
|
|
1588
|
+
schemaColumns,
|
|
1250
1589
|
catalog: nodeCatalog.schemas,
|
|
1251
1590
|
knownTypes: nodeCatalog.knownTypes,
|
|
1252
|
-
fileByName: Object.fromEntries(Object.values(local.byName).map(e => [e.doc.name, `endpoints/${e.doc.name}.yaml`])),
|
|
1591
|
+
fileByName: Object.fromEntries(Object.values(local.byName).map(e => [e.doc.name, `endpoints/${e.file || `${e.doc.name}.yaml`}`])),
|
|
1253
1592
|
// Collected during per-endpoint pass; resolved against the remote AFTER
|
|
1254
1593
|
// all endpoints are checked (one batch query instead of N).
|
|
1255
1594
|
suspectedMissingTables: [],
|
|
@@ -1329,9 +1668,11 @@ export async function runValidation(rootDir, projectId) {
|
|
|
1329
1668
|
for (const err of local.errors || []) {
|
|
1330
1669
|
diagnostics.push({
|
|
1331
1670
|
severity: "error",
|
|
1332
|
-
rule: "file_read_error",
|
|
1671
|
+
rule: err.rule || "file_read_error",
|
|
1333
1672
|
file: err.file,
|
|
1673
|
+
loc: err.loc,
|
|
1334
1674
|
message: err.error,
|
|
1675
|
+
fix_hint: err.fix_hint,
|
|
1335
1676
|
})
|
|
1336
1677
|
}
|
|
1337
1678
|
|
|
@@ -1354,6 +1695,15 @@ export async function runValidation(rootDir, projectId) {
|
|
|
1354
1695
|
}
|
|
1355
1696
|
}
|
|
1356
1697
|
|
|
1698
|
+
export const __testing = {
|
|
1699
|
+
buildNodeOutputContracts,
|
|
1700
|
+
extractRuntimePaths,
|
|
1701
|
+
inferSelectOutputColumns,
|
|
1702
|
+
inferSqlCardinality,
|
|
1703
|
+
unsupportedRuntimePlaceholder,
|
|
1704
|
+
validateNodeOutputRef,
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1357
1707
|
export const dypaiValidateTool = {
|
|
1358
1708
|
name: "dypai_validate",
|
|
1359
1709
|
description:
|