kibi-mcp 0.19.0 → 0.19.2
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/bin/kibi-mcp +1 -1
- package/dist/runtime/mcp-runtime.js +43 -0
- package/dist/server/docs.js +4 -4
- package/dist/server/json-schema-to-zod.js +73 -2
- package/dist/server/session.js +33 -15
- package/dist/server/tool-registration.js +35 -62
- package/dist/server/tools-runtime.js +52 -2
- package/dist/server/tools.js +19 -5
- package/dist/server/transport.js +9 -5
- package/dist/tools/autopilot-generate.js +37 -646
- package/dist/tools/autopilot-test-compat.js +88 -0
- package/dist/tools/autopilot-test-parsers.js +120 -0
- package/dist/tools/check.js +13 -106
- package/dist/tools/coverage.js +11 -25
- package/dist/tools/delete.js +5 -112
- package/dist/tools/discovery-adapter.js +23 -0
- package/dist/tools/entity-query.js +1 -111
- package/dist/tools/find-gaps.js +11 -26
- package/dist/tools/graph.js +11 -24
- package/dist/tools/model-requirement.js +8 -347
- package/dist/tools/mutation-context.js +28 -0
- package/dist/tools/query.js +5 -50
- package/dist/tools/relationship-validation.js +9 -116
- package/dist/tools/search.js +4 -37
- package/dist/tools/semantic-advisor.js +2 -40
- package/dist/tools/skills.js +17 -57
- package/dist/tools/sparql.js +12 -93
- package/dist/tools/status.js +5 -19
- package/dist/tools/suggest-predicates.js +11 -2448
- package/dist/tools/upsert.js +21 -816
- package/dist/tools/validate-upsert.js +12 -48
- package/dist/tools-config.js +75 -802
- package/package.json +4 -3
- package/dist/semantic-advisor/analyze-prose.js +0 -1392
- package/dist/semantic-advisor/prose-coverage-evaluator.js +0 -66
- package/dist/semantic-advisor/types.js +0 -1
package/dist/tools/upsert.js
CHANGED
|
@@ -1,824 +1,29 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
Kibi — repo-local, per-branch, queryable long-term memory for software projects
|
|
5
|
-
Copyright (C) 2026 Piotr Franczyk
|
|
6
|
-
|
|
7
|
-
This program is free software: you can redistribute it and/or modify
|
|
8
|
-
it under the terms of the GNU Affero General Public License as published by
|
|
9
|
-
the Free Software Foundation, either version 3 of the License, or
|
|
10
|
-
(at your option) any later version.
|
|
11
|
-
|
|
12
|
-
This program is distributed in the hope that it will be useful,
|
|
13
|
-
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
-
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
15
|
-
GNU Affero General Public License for more details.
|
|
16
|
-
|
|
17
|
-
You should have received a copy of the GNU Affero General Public License
|
|
18
|
-
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
19
|
-
*/
|
|
20
|
-
import Ajv from "ajv";
|
|
21
|
-
import { escapeAtom, toPrologAtom, toPrologString, } from "kibi-cli/prolog/codec";
|
|
22
|
-
import { SYMBOL_ROLES, getBehavioralSymbolNames, getNonBehavioralSymbolNames, inferSymbolRole, isAllowedGranularityReason, isTraceabilityRelationshipType, } from "kibi-cli/public/symbol-granularity";
|
|
23
|
-
import entitySchema from "kibi-cli/schemas/entity";
|
|
24
|
-
import relationshipSchema from "kibi-cli/schemas/relationship";
|
|
25
|
-
import { Project, ScriptKind } from "ts-morph";
|
|
1
|
+
import { upsertSpec } from "kibi-cli/operations";
|
|
2
|
+
import { setSymbolRefreshForTests } from "kibi-cli/operations/mutation/symbol-refresh";
|
|
3
|
+
import { validateUpsertInput } from "kibi-cli/operations/mutation/validation";
|
|
26
4
|
import { isMcpDebugEnabled } from "../env.js";
|
|
27
|
-
import {
|
|
28
|
-
import { readBranchKbStamp } from "../server/kb-freshness.js";
|
|
29
|
-
import { attachedBranchKbPath, updateAttachedBranchStamp, } from "../server/session.js";
|
|
30
|
-
import { formatInvalidRelationshipError, formatRelationshipSourceMismatch, validateLiveRelationshipTargets, } from "./relationship-validation.js";
|
|
31
|
-
import { refreshCoordinatesForSymbolId } from "./symbols.js";
|
|
32
|
-
let refreshCoordinatesForSymbolIdImpl = refreshCoordinatesForSymbolId;
|
|
33
|
-
const ajv = new Ajv({ strict: false });
|
|
34
|
-
const entitySchemaRecord = entitySchema;
|
|
35
|
-
const entitySchemaProperties = entitySchemaRecord.properties;
|
|
36
|
-
const normalizedEntitySchemaProperties = entitySchemaProperties !== null &&
|
|
37
|
-
typeof entitySchemaProperties === "object" &&
|
|
38
|
-
!Array.isArray(entitySchemaProperties)
|
|
39
|
-
? entitySchemaProperties
|
|
40
|
-
: {};
|
|
41
|
-
const validateEntity = ajv.compile({
|
|
42
|
-
...entitySchemaRecord,
|
|
43
|
-
properties: {
|
|
44
|
-
...normalizedEntitySchemaProperties,
|
|
45
|
-
granularity_reason: {
|
|
46
|
-
type: "string",
|
|
47
|
-
enum: [
|
|
48
|
-
"config-artifact",
|
|
49
|
-
"module-level-behavior",
|
|
50
|
-
"extractor-miss",
|
|
51
|
-
"legacy-link",
|
|
52
|
-
],
|
|
53
|
-
},
|
|
54
|
-
symbol_role: {
|
|
55
|
-
type: "string",
|
|
56
|
-
enum: [...SYMBOL_ROLES],
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
});
|
|
60
|
-
const validateRelationship = ajv.compile(relationshipSchema);
|
|
61
|
-
const PROPERTY_ALIAS_HINTS = new Map([
|
|
62
|
-
["subjectKey", "subject_key"],
|
|
63
|
-
["propertyKey", "property_key"],
|
|
64
|
-
["predicateName", "predicate_name"],
|
|
65
|
-
["predicateArgs", "predicate_args"],
|
|
66
|
-
["canonicalKey", "canonical_key"],
|
|
67
|
-
["closedWorld", "closed_world"],
|
|
68
|
-
]);
|
|
69
|
-
const PROPERTY_VALUE_FIELDS = [
|
|
70
|
-
"value_string",
|
|
71
|
-
"value_int",
|
|
72
|
-
"value_number",
|
|
73
|
-
"value_bool",
|
|
74
|
-
];
|
|
75
|
-
function valueFieldHint(value) {
|
|
76
|
-
if (typeof value === "boolean") {
|
|
77
|
-
return `Use value_type: "bool" plus value_bool: ${String(value)}.`;
|
|
78
|
-
}
|
|
79
|
-
if (typeof value === "number") {
|
|
80
|
-
return Number.isInteger(value)
|
|
81
|
-
? `Use value_type: "int" plus value_int: ${String(value)}.`
|
|
82
|
-
: `Use value_type: "number" plus value_number: ${String(value)}.`;
|
|
83
|
-
}
|
|
84
|
-
if (typeof value === "string") {
|
|
85
|
-
return `Use value_type: "string" plus value_string: ${JSON.stringify(value)}.`;
|
|
86
|
-
}
|
|
87
|
-
return "Use value_type plus exactly one of value_string, value_int, value_number, or value_bool.";
|
|
88
|
-
}
|
|
89
|
-
function ajvErrorParams(error) {
|
|
90
|
-
return error.params;
|
|
91
|
-
}
|
|
92
|
-
function factKindShapeHints(entity) {
|
|
93
|
-
if (entity.type !== "fact")
|
|
94
|
-
return [];
|
|
95
|
-
if (entity.fact_kind === "property_value") {
|
|
96
|
-
const missing = [
|
|
97
|
-
"subject_key",
|
|
98
|
-
"property_key",
|
|
99
|
-
"operator",
|
|
100
|
-
"value_type",
|
|
101
|
-
].filter((field) => entity[field] === undefined);
|
|
102
|
-
const presentValueFields = PROPERTY_VALUE_FIELDS.filter((field) => entity[field] !== undefined);
|
|
103
|
-
const hints = [];
|
|
104
|
-
if (missing.length > 0) {
|
|
105
|
-
hints.push(`fact_kind 'property_value' requires ${missing.join(", ")}.`);
|
|
106
|
-
}
|
|
107
|
-
if (presentValueFields.length !== 1) {
|
|
108
|
-
hints.push("fact_kind 'property_value' requires exactly one typed value field: value_string, value_int, value_number, or value_bool.");
|
|
109
|
-
}
|
|
110
|
-
if (hints.length > 0) {
|
|
111
|
-
hints.push("Next action: use kb_model_requirement for prose claims, or provide subject_key, property_key, operator, value_type, and one value_* field in kb_upsert.properties.");
|
|
112
|
-
}
|
|
113
|
-
return hints;
|
|
114
|
-
}
|
|
115
|
-
if (entity.fact_kind === "predicate") {
|
|
116
|
-
const predicateArgs = entity.predicate_args;
|
|
117
|
-
const hasPredicateArgs = Array.isArray(predicateArgs) && predicateArgs.length > 0;
|
|
118
|
-
const missing = [
|
|
119
|
-
...(entity.predicate_name === undefined ? ["predicate_name"] : []),
|
|
120
|
-
...(!hasPredicateArgs ? ["predicate_args"] : []),
|
|
121
|
-
...(entity.canonical_key === undefined ? ["canonical_key"] : []),
|
|
122
|
-
];
|
|
123
|
-
const hints = [];
|
|
124
|
-
if (missing.length > 0) {
|
|
125
|
-
hints.push(`fact_kind 'predicate' requires ${missing.join(", ")}.`);
|
|
126
|
-
}
|
|
127
|
-
if (hints.length > 0) {
|
|
128
|
-
hints.push("Next action: call kb_suggest_predicates before hand-writing ontology predicate facts.");
|
|
129
|
-
}
|
|
130
|
-
return hints;
|
|
131
|
-
}
|
|
132
|
-
return [];
|
|
133
|
-
}
|
|
134
|
-
function formatEntityValidationErrors(entity, errors) {
|
|
135
|
-
const messages = errors.map((error) => {
|
|
136
|
-
const path = error.instancePath || "root";
|
|
137
|
-
const params = ajvErrorParams(error);
|
|
138
|
-
if (error.keyword === "additionalProperties" && params.additionalProperty) {
|
|
139
|
-
const property = params.additionalProperty;
|
|
140
|
-
const suggested = PROPERTY_ALIAS_HINTS.get(property);
|
|
141
|
-
if (property === "value") {
|
|
142
|
-
return `${path}: unknown property 'value'. ${valueFieldHint(entity.value)} Do not use generic value in kb_upsert.properties.`;
|
|
143
|
-
}
|
|
144
|
-
if (suggested) {
|
|
145
|
-
return `${path}: unknown property '${property}'. Did you mean '${suggested}'? kb_upsert.properties uses snake_case typed fact fields.`;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (error.keyword === "enum" && params.allowedValues) {
|
|
149
|
-
return `${path}: ${error.message}. Allowed values: ${params.allowedValues.map(String).join(", ")}`;
|
|
150
|
-
}
|
|
151
|
-
return `${path}: ${error.message}`;
|
|
152
|
-
});
|
|
153
|
-
const extraHints = factKindShapeHints(entity);
|
|
154
|
-
for (const [alias, canonical] of PROPERTY_ALIAS_HINTS) {
|
|
155
|
-
if (Object.prototype.hasOwnProperty.call(entity, alias)) {
|
|
156
|
-
extraHints.push(`Unknown property '${alias}'. Use '${canonical}' in kb_upsert.properties.`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
if (Object.prototype.hasOwnProperty.call(entity, "value")) {
|
|
160
|
-
extraHints.push(valueFieldHint(entity.value));
|
|
161
|
-
}
|
|
162
|
-
if (Object.keys(entity).some((key) => PROPERTY_ALIAS_HINTS.has(key)) ||
|
|
163
|
-
Object.prototype.hasOwnProperty.call(entity, "value")) {
|
|
164
|
-
extraHints.push("Next action: if starting from prose, call kb_model_requirement and apply its sequential applyPlan instead of guessing field names.");
|
|
165
|
-
}
|
|
166
|
-
return [...messages, ...extraHints].join("; ");
|
|
167
|
-
}
|
|
168
|
-
function validateFactModelingShape(entity) {
|
|
169
|
-
const hints = factKindShapeHints(entity);
|
|
170
|
-
if (hints.length > 0) {
|
|
171
|
-
throw new Error(`Entity validation failed: ${hints.join("; ")}`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Handle kb.upsert tool calls
|
|
176
|
-
* Accepts { type, id, properties } — the flat format matching the tool schema.
|
|
177
|
-
* Validates the assembled entity against JSON Schema before Prolog writes.
|
|
178
|
-
* implements REQ-002, REQ-011
|
|
179
|
-
*/
|
|
5
|
+
import { createMutationContext } from "./mutation-context.js";
|
|
180
6
|
export async function handleKbUpsert(prolog, args) {
|
|
181
|
-
|
|
182
|
-
const semanticAdvisor = analyzeSemanticAdvisorInput({
|
|
183
|
-
payload: { ...args },
|
|
184
|
-
});
|
|
185
|
-
const type = entity.type;
|
|
186
|
-
const entities = [entity];
|
|
187
|
-
// If relationships are not explicitly provided, preserve existing ones.
|
|
188
|
-
// This prevents accidental relationship loss when updating only properties.
|
|
189
|
-
let effectiveRelationships = relationships;
|
|
190
|
-
let created = 0;
|
|
191
|
-
let updated = 0;
|
|
192
|
-
let relationshipsCreated = 0;
|
|
193
|
-
try {
|
|
194
|
-
if ((args.relationships === undefined ||
|
|
195
|
-
(Array.isArray(args.relationships) &&
|
|
196
|
-
args.relationships.length === 0)) &&
|
|
197
|
-
args.id) {
|
|
198
|
-
// Preserve relationships on updates when the request omits relationships
|
|
199
|
-
// OR provides an empty array. This avoids accidental edge deletion by
|
|
200
|
-
// clients that always serialize `relationships: []` for partial updates.
|
|
201
|
-
// For creates, this remains cheap because existence is checked first.
|
|
202
|
-
const existsResult = await prolog.query(`once(kb_entity('${escapeAtom(args.id)}', _, _))`);
|
|
203
|
-
if (existsResult.success) {
|
|
204
|
-
const existing = await fetchExistingRelationships(prolog, args.id);
|
|
205
|
-
if (existing.length > 0) {
|
|
206
|
-
effectiveRelationships = existing;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
// Validate strict-lane fact_kind pairing for constrains/requires_property
|
|
211
|
-
// implements REQ-011
|
|
212
|
-
await validateStrictLanePairing(prolog, effectiveRelationships);
|
|
213
|
-
await validateLiveRelationshipTargets(prolog, entity, effectiveRelationships);
|
|
214
|
-
// Process entities
|
|
215
|
-
for (const entity of entities) {
|
|
216
|
-
const id = entity.id;
|
|
217
|
-
const type = entity.type;
|
|
218
|
-
// Check if entity exists before transaction (to determine created vs updated)
|
|
219
|
-
const checkGoal = `once(kb_entity('${escapeAtom(id)}', _, _))`;
|
|
220
|
-
const checkResult = await prolog.query(checkGoal);
|
|
221
|
-
const isUpdate = checkResult.success;
|
|
222
|
-
// Build property list for Prolog
|
|
223
|
-
const props = buildPropertyList(entity);
|
|
224
|
-
// Build relationship goals
|
|
225
|
-
const relationshipGoals = [];
|
|
226
|
-
for (const rel of effectiveRelationships) {
|
|
227
|
-
const relType = rel.type;
|
|
228
|
-
const from = rel.from;
|
|
229
|
-
const to = rel.to;
|
|
230
|
-
const metadata = buildRelationshipMetadata(rel);
|
|
231
|
-
relationshipGoals.push(`kb_assert_relationship_no_audit(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
|
|
232
|
-
}
|
|
233
|
-
// Build atomic transaction goal wrapping entity + all relationships
|
|
234
|
-
// For requirements, also include contradiction check within the transaction
|
|
235
|
-
// implements REQ-002, REQ-011
|
|
236
|
-
let transactionGoal;
|
|
237
|
-
const needsContradictionCheck = type === "req" && !args._skipContradictionCheck;
|
|
238
|
-
if (relationshipGoals.length === 0) {
|
|
239
|
-
// Simple case: just entity
|
|
240
|
-
if (needsContradictionCheck) {
|
|
241
|
-
transactionGoal = `rdf_transaction((kb_assert_entity_no_audit(${type}, ${props}), check_req_contradiction('${escapeAtom(id)}')))`;
|
|
242
|
-
}
|
|
243
|
-
else {
|
|
244
|
-
transactionGoal = `rdf_transaction((kb_assert_entity_no_audit(${type}, ${props})))`;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
else {
|
|
248
|
-
// Entity + relationships in one transaction
|
|
249
|
-
const goals = [
|
|
250
|
-
`kb_assert_entity_no_audit(${type}, ${props})`,
|
|
251
|
-
...relationshipGoals,
|
|
252
|
-
].join(", ");
|
|
253
|
-
if (needsContradictionCheck) {
|
|
254
|
-
transactionGoal = `rdf_transaction((${goals}, check_req_contradiction('${escapeAtom(id)}')))`;
|
|
255
|
-
}
|
|
256
|
-
else {
|
|
257
|
-
transactionGoal = `rdf_transaction((${goals}))`;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
const txResult = await prolog.query(transactionGoal);
|
|
261
|
-
if (!txResult.success) {
|
|
262
|
-
// Format error message without exposing raw transaction goal
|
|
263
|
-
const formattedError = formatUpsertError(id, txResult.error);
|
|
264
|
-
throw new Error(formattedError);
|
|
265
|
-
}
|
|
266
|
-
await recordEntityAudit(prolog, isUpdate ? "updated" : "created", type, entity);
|
|
267
|
-
for (const rel of effectiveRelationships) {
|
|
268
|
-
await recordRelationshipAudit(prolog, rel);
|
|
269
|
-
}
|
|
270
|
-
// Update counters
|
|
271
|
-
if (isUpdate) {
|
|
272
|
-
updated++;
|
|
273
|
-
}
|
|
274
|
-
else {
|
|
275
|
-
created++;
|
|
276
|
-
}
|
|
277
|
-
relationshipsCreated += effectiveRelationships.length;
|
|
278
|
-
}
|
|
279
|
-
// Save KB to disk after all entities/relationships are written to ensure
|
|
280
|
-
// durability across process restarts.
|
|
281
|
-
prolog.invalidateCache();
|
|
282
|
-
const saveResult = await prolog.query("kb_save");
|
|
283
|
-
if (!saveResult.success) {
|
|
284
|
-
throw new Error(`Failed to save KB after upsert: ${saveResult.error || "Unknown error"}`);
|
|
285
|
-
}
|
|
286
|
-
// Update session stamp so kb_check (and other tools) don't trigger a
|
|
287
|
-
// stale-detach refresh that would unload the just-saved runtime state.
|
|
288
|
-
if (attachedBranchKbPath) {
|
|
289
|
-
try {
|
|
290
|
-
const freshStamp = await readBranchKbStamp(attachedBranchKbPath);
|
|
291
|
-
updateAttachedBranchStamp(freshStamp);
|
|
292
|
-
}
|
|
293
|
-
catch {
|
|
294
|
-
// Non-fatal: stamp update is best-effort to avoid spurious refresh.
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
if (type === "symbol") {
|
|
298
|
-
try {
|
|
299
|
-
await refreshCoordinatesForSymbolIdImpl(entity.id);
|
|
300
|
-
}
|
|
301
|
-
catch (error) {
|
|
302
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
303
|
-
if (isMcpDebugEnabled()) {
|
|
304
|
-
console.warn(`[KIBI-MCP] Symbol coordinate auto-refresh failed for ${String(entity.id)}: ${message}`);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
// Check for scenario-coverage guidance
|
|
309
|
-
const coverageWarnings = await checkScenarioCoverageGuidance(prolog, relationships, type, entity.id);
|
|
310
|
-
return {
|
|
311
|
-
content: [
|
|
312
|
-
{
|
|
313
|
-
type: "text",
|
|
314
|
-
text: `Upserted ${String(entity.id)} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
|
|
315
|
-
},
|
|
316
|
-
],
|
|
317
|
-
structuredContent: {
|
|
318
|
-
created,
|
|
319
|
-
updated,
|
|
320
|
-
relationships_created: relationshipsCreated,
|
|
321
|
-
warnings: [...semanticAdvisor.warnings, ...coverageWarnings],
|
|
322
|
-
semanticAdvisor: semanticAdvisor.receipt,
|
|
323
|
-
...(type === "req"
|
|
324
|
-
? {
|
|
325
|
-
contradictionCheck: {
|
|
326
|
-
outcome: args._skipContradictionCheck
|
|
327
|
-
? "skipped"
|
|
328
|
-
: "no-conflict",
|
|
329
|
-
checked_req_id: entity.id,
|
|
330
|
-
strict_readiness: semanticAdvisor.receipt.logic_readiness === "modeled"
|
|
331
|
-
? "modeled"
|
|
332
|
-
: semanticAdvisor.receipt.candidate_lane,
|
|
333
|
-
},
|
|
334
|
-
}
|
|
335
|
-
: {}),
|
|
336
|
-
},
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
catch (error) {
|
|
340
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
341
|
-
throw new Error(`Upsert execution failed: ${message}`);
|
|
342
|
-
}
|
|
7
|
+
return upsertSpec.execute(args, createMutationContext(prolog));
|
|
343
8
|
}
|
|
344
9
|
export function validateKbUpsertArgs(args) {
|
|
345
|
-
|
|
346
|
-
if (!type || !id) {
|
|
347
|
-
throw new Error("'type' and 'id' are required for upsert");
|
|
348
|
-
}
|
|
349
|
-
const entity = {
|
|
350
|
-
id,
|
|
351
|
-
type,
|
|
352
|
-
...properties,
|
|
353
|
-
};
|
|
354
|
-
if (!entity.created_at) {
|
|
355
|
-
entity.created_at = new Date().toISOString();
|
|
356
|
-
}
|
|
357
|
-
if (!entity.updated_at) {
|
|
358
|
-
entity.updated_at = new Date().toISOString();
|
|
359
|
-
}
|
|
360
|
-
if (!entity.source) {
|
|
361
|
-
entity.source = "mcp://kibi/upsert";
|
|
362
|
-
}
|
|
363
|
-
if (!validateEntity(entity)) {
|
|
364
|
-
const errors = validateEntity.errors || [];
|
|
365
|
-
const errorMessages = formatEntityValidationErrors(entity, errors);
|
|
366
|
-
throw new Error(`Entity validation failed: ${errorMessages}`);
|
|
367
|
-
}
|
|
368
|
-
validateFactModelingShape(entity);
|
|
369
|
-
for (let i = 0; i < relationships.length; i++) {
|
|
370
|
-
const rel = relationships[i];
|
|
371
|
-
if (!validateRelationship(rel)) {
|
|
372
|
-
const errors = validateRelationship.errors || [];
|
|
373
|
-
const errorMessages = errors
|
|
374
|
-
.map((e) => `${e.instancePath || "root"}: ${e.message}`)
|
|
375
|
-
.join("; ");
|
|
376
|
-
throw new Error(`Relationship validation failed at index ${i}: ${errorMessages}`);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
validateRelationshipSources(id, relationships);
|
|
380
|
-
validateSymbolGranularity(entity, relationships);
|
|
381
|
-
return { entity, relationships };
|
|
382
|
-
}
|
|
383
|
-
function chooseScriptKind(filePath) {
|
|
384
|
-
const lower = filePath.toLowerCase();
|
|
385
|
-
if (lower.endsWith(".tsx"))
|
|
386
|
-
return ScriptKind.TSX;
|
|
387
|
-
if (lower.endsWith(".ts") ||
|
|
388
|
-
lower.endsWith(".mts") ||
|
|
389
|
-
lower.endsWith(".cts")) {
|
|
390
|
-
return ScriptKind.TS;
|
|
391
|
-
}
|
|
392
|
-
if (lower.endsWith(".jsx"))
|
|
393
|
-
return ScriptKind.JSX;
|
|
394
|
-
return ScriptKind.JS;
|
|
395
|
-
}
|
|
396
|
-
function hasTraceabilityRelationship(relationships) {
|
|
397
|
-
return relationships.some((relationship) => isTraceabilityRelationshipType(relationship.type));
|
|
398
|
-
}
|
|
399
|
-
function hasAllowedGranularityReason(entity) {
|
|
400
|
-
return isAllowedGranularityReason(entity.granularity_reason);
|
|
401
|
-
}
|
|
402
|
-
function createSymbolCandidate(name, kind) {
|
|
403
|
-
return {
|
|
404
|
-
name,
|
|
405
|
-
kind,
|
|
406
|
-
role: inferSymbolRole(kind),
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
function collectGranularSymbolCandidates(filePath, content) {
|
|
410
|
-
const project = new Project({ skipAddingFilesFromTsConfig: true });
|
|
411
|
-
const sourceFile = project.createSourceFile(`${filePath}::granularity`, content, {
|
|
412
|
-
overwrite: true,
|
|
413
|
-
scriptKind: chooseScriptKind(filePath),
|
|
414
|
-
});
|
|
415
|
-
const candidates = [];
|
|
416
|
-
const methodNameCounts = new Map();
|
|
417
|
-
const bareMethodCandidates = new Map();
|
|
418
|
-
for (const fn of sourceFile.getFunctions()) {
|
|
419
|
-
if (fn.isExported()) {
|
|
420
|
-
const name = fn.getName();
|
|
421
|
-
if (name)
|
|
422
|
-
candidates.push(createSymbolCandidate(name, "function"));
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
for (const cls of sourceFile.getClasses()) {
|
|
426
|
-
if (cls.isExported()) {
|
|
427
|
-
const name = cls.getName();
|
|
428
|
-
if (name)
|
|
429
|
-
candidates.push(createSymbolCandidate(name, "class"));
|
|
430
|
-
for (const method of cls.getMethods()) {
|
|
431
|
-
const methodName = method.getName();
|
|
432
|
-
if (name) {
|
|
433
|
-
candidates.push(createSymbolCandidate(`${name}.${methodName}`, "method"));
|
|
434
|
-
}
|
|
435
|
-
bareMethodCandidates.set(methodName, createSymbolCandidate(methodName, "method"));
|
|
436
|
-
methodNameCounts.set(methodName, (methodNameCounts.get(methodName) ?? 0) + 1);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
for (const [methodName, count] of methodNameCounts) {
|
|
441
|
-
const candidate = bareMethodCandidates.get(methodName);
|
|
442
|
-
if (count === 1 && candidate)
|
|
443
|
-
candidates.push(candidate);
|
|
444
|
-
}
|
|
445
|
-
for (const iface of sourceFile.getInterfaces()) {
|
|
446
|
-
if (iface.isExported()) {
|
|
447
|
-
candidates.push(createSymbolCandidate(iface.getName(), "interface"));
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
for (const alias of sourceFile.getTypeAliases()) {
|
|
451
|
-
if (alias.isExported()) {
|
|
452
|
-
candidates.push(createSymbolCandidate(alias.getName(), "type"));
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
for (const en of sourceFile.getEnums()) {
|
|
456
|
-
if (en.isExported())
|
|
457
|
-
candidates.push(createSymbolCandidate(en.getName(), "enum"));
|
|
458
|
-
}
|
|
459
|
-
return candidates.sort((a, b) => a.name.localeCompare(b.name));
|
|
460
|
-
}
|
|
461
|
-
function validateSymbolGranularity(entity, relationships) {
|
|
462
|
-
if (entity.type !== "symbol")
|
|
463
|
-
return;
|
|
464
|
-
if (!hasTraceabilityRelationship(relationships))
|
|
465
|
-
return;
|
|
466
|
-
if (hasAllowedGranularityReason(entity))
|
|
467
|
-
return;
|
|
468
|
-
if (typeof entity.sourceFile !== "string")
|
|
469
|
-
return;
|
|
470
|
-
if (typeof entity.title !== "string")
|
|
471
|
-
return;
|
|
472
|
-
const sourcePath = path.isAbsolute(entity.sourceFile)
|
|
473
|
-
? entity.sourceFile
|
|
474
|
-
: path.resolve(process.cwd(), entity.sourceFile);
|
|
475
|
-
if (!existsSync(sourcePath))
|
|
476
|
-
return;
|
|
477
|
-
const candidates = collectGranularSymbolCandidates(entity.sourceFile, readFileSync(sourcePath, "utf8"));
|
|
478
|
-
const candidateNames = [
|
|
479
|
-
...new Set(candidates.map((candidate) => candidate.name)),
|
|
480
|
-
];
|
|
481
|
-
if (candidateNames.includes(entity.title))
|
|
482
|
-
return;
|
|
483
|
-
const behavioralNames = getBehavioralSymbolNames(candidates);
|
|
484
|
-
if (behavioralNames.length === 0)
|
|
485
|
-
return;
|
|
486
|
-
const nonBehavioralNames = getNonBehavioralSymbolNames(candidates);
|
|
487
|
-
const maxNamesInMessage = 10;
|
|
488
|
-
const shownBehavioral = behavioralNames.slice(0, maxNamesInMessage);
|
|
489
|
-
const hiddenBehavioralCount = behavioralNames.length - shownBehavioral.length;
|
|
490
|
-
const behavioralList = shownBehavioral.join(", ") +
|
|
491
|
-
(hiddenBehavioralCount > 0 ? `, and ${hiddenBehavioralCount} more` : "");
|
|
492
|
-
const shownNonBehavioral = nonBehavioralNames.slice(0, maxNamesInMessage);
|
|
493
|
-
const hiddenNonBehavioralCount = nonBehavioralNames.length - shownNonBehavioral.length;
|
|
494
|
-
const nonBehavioralList = shownNonBehavioral.join(", ") +
|
|
495
|
-
(hiddenNonBehavioralCount > 0
|
|
496
|
-
? `, and ${hiddenNonBehavioralCount} more`
|
|
497
|
-
: "");
|
|
498
|
-
const ignoredSymbolsMessage = nonBehavioralNames.length > 0
|
|
499
|
-
? ` Non-behavioral symbols in the file were ignored for this decision: ${nonBehavioralList}.`
|
|
500
|
-
: "";
|
|
501
|
-
throw new Error(`Symbol ${String(entity.id)} links ${entity.sourceFile} coarsely while granular symbols are available (behavioral only): ${behavioralList}. Move relationships to a behavioral symbol, add a manifest behavioral anchor, or set granularity_reason to config-artifact, module-level-behavior, extractor-miss, or legacy-link.${ignoredSymbolsMessage}`);
|
|
10
|
+
return validateUpsertInput(args, new Date());
|
|
502
11
|
}
|
|
503
12
|
export const __test__ = {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
13
|
+
setRefreshCoordinatesForSymbolIdForTests(refresh) {
|
|
14
|
+
setSymbolRefreshForTests(refresh === undefined
|
|
15
|
+
? undefined
|
|
16
|
+
: async (symbolId) => {
|
|
17
|
+
try {
|
|
18
|
+
return await refresh(symbolId);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
if (isMcpDebugEnabled()) {
|
|
23
|
+
console.warn(`[KIBI-MCP] Symbol coordinate auto-refresh failed for ${symbolId}: ${message}`);
|
|
24
|
+
}
|
|
25
|
+
return { refreshed: false, found: false };
|
|
26
|
+
}
|
|
27
|
+
});
|
|
507
28
|
},
|
|
508
29
|
};
|
|
509
|
-
/**
|
|
510
|
-
* Build Prolog property list from entity object
|
|
511
|
-
* Returns simple Key=Value format without typed literals
|
|
512
|
-
* Example output: "[id='test-1', title=\"Test\", status=active]"
|
|
513
|
-
* implements REQ-002
|
|
514
|
-
*/
|
|
515
|
-
function buildPropertyList(entity) {
|
|
516
|
-
const pairs = [];
|
|
517
|
-
// Defined internally to ensure thread safety and avoid initialization order issues.
|
|
518
|
-
// Using simple arrays instead of Sets is performant enough for small lists and avoids Set allocation overhead.
|
|
519
|
-
// implements REQ-002
|
|
520
|
-
const ATOM_FIELDS = [
|
|
521
|
-
"status",
|
|
522
|
-
"owner",
|
|
523
|
-
"priority",
|
|
524
|
-
"severity",
|
|
525
|
-
"symbol_role",
|
|
526
|
-
"granularity_reason",
|
|
527
|
-
"verification_scope",
|
|
528
|
-
"verification_perspective",
|
|
529
|
-
// Typed fact enum fields must be atoms for Prolog validation
|
|
530
|
-
"fact_kind",
|
|
531
|
-
"operator",
|
|
532
|
-
"value_type",
|
|
533
|
-
"polarity",
|
|
534
|
-
];
|
|
535
|
-
const STRING_FIELDS = [
|
|
536
|
-
"id",
|
|
537
|
-
"title",
|
|
538
|
-
"created_at",
|
|
539
|
-
"updated_at",
|
|
540
|
-
"source",
|
|
541
|
-
"text_ref",
|
|
542
|
-
];
|
|
543
|
-
for (const [key, value] of Object.entries(entity)) {
|
|
544
|
-
if (key === "type")
|
|
545
|
-
continue;
|
|
546
|
-
if (value === undefined || value === null)
|
|
547
|
-
continue;
|
|
548
|
-
let prologValue;
|
|
549
|
-
if (key === "id" && typeof value === "string") {
|
|
550
|
-
prologValue = `'${value.replace(/'/g, "''")}'`;
|
|
551
|
-
}
|
|
552
|
-
else if (Array.isArray(value)) {
|
|
553
|
-
prologValue = JSON.stringify(value);
|
|
554
|
-
}
|
|
555
|
-
else if (ATOM_FIELDS.includes(key) && typeof value === "string") {
|
|
556
|
-
prologValue = toPrologAtom(value);
|
|
557
|
-
}
|
|
558
|
-
else if (STRING_FIELDS.includes(key) && typeof value === "string") {
|
|
559
|
-
prologValue = `${toPrologString(value)}`;
|
|
560
|
-
}
|
|
561
|
-
else if (typeof value === "string") {
|
|
562
|
-
prologValue = `${toPrologString(value)}`;
|
|
563
|
-
}
|
|
564
|
-
else if (typeof value === "number") {
|
|
565
|
-
prologValue = String(value);
|
|
566
|
-
}
|
|
567
|
-
else if (typeof value === "boolean") {
|
|
568
|
-
prologValue = value ? "true" : "false";
|
|
569
|
-
}
|
|
570
|
-
else {
|
|
571
|
-
prologValue = `${toPrologString(String(value))}`;
|
|
572
|
-
}
|
|
573
|
-
pairs.push(`${key}=${prologValue}`);
|
|
574
|
-
}
|
|
575
|
-
return `[${pairs.join(", ")}]`;
|
|
576
|
-
}
|
|
577
|
-
/**
|
|
578
|
-
* Build Prolog metadata list for relationship
|
|
579
|
-
* Returns simple Key=Value format without typed literals
|
|
580
|
-
*/
|
|
581
|
-
function buildRelationshipMetadata(rel) {
|
|
582
|
-
const pairs = [];
|
|
583
|
-
for (const [key, value] of Object.entries(rel)) {
|
|
584
|
-
if (key === "type" || key === "from" || key === "to")
|
|
585
|
-
continue;
|
|
586
|
-
let prologValue;
|
|
587
|
-
if (typeof value === "string") {
|
|
588
|
-
prologValue = `${toPrologString(value)}`;
|
|
589
|
-
}
|
|
590
|
-
else if (typeof value === "number") {
|
|
591
|
-
prologValue = String(value);
|
|
592
|
-
}
|
|
593
|
-
else {
|
|
594
|
-
prologValue = `${toPrologString(String(value))}`;
|
|
595
|
-
}
|
|
596
|
-
pairs.push(`${key}=${prologValue}`);
|
|
597
|
-
}
|
|
598
|
-
return `[${pairs.join(", ")}]`;
|
|
599
|
-
}
|
|
600
|
-
/**
|
|
601
|
-
* Ensure all relationship rows belong to the entity being upserted.
|
|
602
|
-
* Rejects foreign-source relationship writes in the same request.
|
|
603
|
-
* implements REQ-002, REQ-011
|
|
604
|
-
*/
|
|
605
|
-
function validateRelationshipSources(entityId, relationships) {
|
|
606
|
-
for (const rel of relationships) {
|
|
607
|
-
if (rel.from !== entityId) {
|
|
608
|
-
throw new Error(formatRelationshipSourceMismatch(entityId, rel));
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
/**
|
|
613
|
-
* Validate strict-lane fact_kind pairing for constrains/requires_property relationships.
|
|
614
|
-
* constrains targets must be subject, observation, or meta facts (or legacy without fact_kind).
|
|
615
|
-
* requires_property targets must be property_value, observation, or meta facts (or legacy without fact_kind).
|
|
616
|
-
* implements REQ-011
|
|
617
|
-
*/
|
|
618
|
-
async function validateStrictLanePairing(prolog, relationships) {
|
|
619
|
-
// implements REQ-011
|
|
620
|
-
for (const rel of relationships) {
|
|
621
|
-
if (rel.type === "constrains") {
|
|
622
|
-
const targetId = rel.to;
|
|
623
|
-
// Reject if target is a fact with fact_kind=property_value.
|
|
624
|
-
// Allow: legacy (no fact_kind), subject, observation, meta, or non-existent
|
|
625
|
-
// (non-existent targets are caught by relationship type validation elsewhere).
|
|
626
|
-
const rejectResult = await prolog.query(`once((kb_entity('${escapeAtom(targetId)}', fact, _SlpProps), memberchk(fact_kind=_SlpFK, _SlpProps), normalize_term_atom(_SlpFK, property_value)))`);
|
|
627
|
-
if (rejectResult.success) {
|
|
628
|
-
throw new Error(`Relationship 'constrains' requires target '${targetId}' to be a subject, observation, or meta fact. Property_value facts cannot be direct targets of constrains relationships.`);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
else if (rel.type === "requires_property") {
|
|
632
|
-
const targetId = rel.to;
|
|
633
|
-
// Reject if target is a fact with fact_kind=subject.
|
|
634
|
-
// Allow: legacy (no fact_kind), property_value, observation, meta, or non-existent.
|
|
635
|
-
const rejectResult = await prolog.query(`once((kb_entity('${escapeAtom(targetId)}', fact, _SlpProps), memberchk(fact_kind=_SlpFK, _SlpProps), normalize_term_atom(_SlpFK, subject)))`);
|
|
636
|
-
if (rejectResult.success) {
|
|
637
|
-
throw new Error(`Relationship 'requires_property' requires target '${targetId}' to be a property_value, observation, or meta fact. Subject facts cannot be direct targets of requires_property relationships.`);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
/**
|
|
643
|
-
* Check for scenario-coverage guidance when verified_by is added to a
|
|
644
|
-
* requirement that already has scenarios (specified_by relationships).
|
|
645
|
-
* Returns non-blocking warnings; never throws.
|
|
646
|
-
*/
|
|
647
|
-
async function checkScenarioCoverageGuidance(prolog, relationships, entityType, entityId) {
|
|
648
|
-
const warnings = [];
|
|
649
|
-
if (entityType !== "req")
|
|
650
|
-
return warnings;
|
|
651
|
-
try {
|
|
652
|
-
for (const rel of relationships) {
|
|
653
|
-
const relType = rel.type;
|
|
654
|
-
if (relType !== "verified_by")
|
|
655
|
-
continue;
|
|
656
|
-
// Check if this requirement has scenarios
|
|
657
|
-
const scenarioQuery = `kb_relationship(specified_by, '${escapeAtom(entityId)}', ScenarioId)`;
|
|
658
|
-
const scenarioResult = await prolog.query(`once(${scenarioQuery})`);
|
|
659
|
-
if (scenarioResult.success) {
|
|
660
|
-
warnings.push(`Scenario-backed coverage: verified_by(${entityId},test) is valid but will not satisfy symbol-coverage because ${entityId} has specified_by a scenario. Use verified_by(scenario,test) or validates(test,scenario) instead.`);
|
|
661
|
-
break; // One warning per entity is enough
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
catch {
|
|
666
|
-
// Non-blocking: never fail the upsert
|
|
667
|
-
}
|
|
668
|
-
return warnings;
|
|
669
|
-
}
|
|
670
|
-
/**
|
|
671
|
-
* Query existing relationships for an entity from the live KB.
|
|
672
|
-
* Used to preserve relationships when the upsert request omits the
|
|
673
|
-
* relationships field (entity-only property update).
|
|
674
|
-
*/
|
|
675
|
-
async function fetchExistingRelationships(prolog, entityId) {
|
|
676
|
-
const relTypes = [
|
|
677
|
-
"depends_on",
|
|
678
|
-
"specified_by",
|
|
679
|
-
"verified_by",
|
|
680
|
-
"validates",
|
|
681
|
-
"implements",
|
|
682
|
-
"covered_by",
|
|
683
|
-
"executable_for",
|
|
684
|
-
"constrained_by",
|
|
685
|
-
"constrains",
|
|
686
|
-
"requires_property",
|
|
687
|
-
"requires_predicate",
|
|
688
|
-
"guards",
|
|
689
|
-
"publishes",
|
|
690
|
-
"consumes",
|
|
691
|
-
"supersedes",
|
|
692
|
-
"relates_to",
|
|
693
|
-
];
|
|
694
|
-
const existing = [];
|
|
695
|
-
try {
|
|
696
|
-
for (const relType of relTypes) {
|
|
697
|
-
const forwardGoal = `findall(To, kb_relationship(${relType}, '${escapeAtom(entityId)}', To), Targets)`;
|
|
698
|
-
const forwardResult = await prolog.query(forwardGoal);
|
|
699
|
-
if (forwardResult.success && forwardResult.bindings.Targets) {
|
|
700
|
-
const targetsStr = forwardResult.bindings.Targets;
|
|
701
|
-
const targets = parsePrologList(targetsStr);
|
|
702
|
-
for (const to of targets) {
|
|
703
|
-
existing.push({ type: relType, from: entityId, to });
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
const reverseGoal = `findall(From, kb_relationship(${relType}, From, '${escapeAtom(entityId)}'), Sources)`;
|
|
707
|
-
const reverseResult = await prolog.query(reverseGoal);
|
|
708
|
-
if (reverseResult.success && reverseResult.bindings.Sources) {
|
|
709
|
-
const sourcesStr = reverseResult.bindings.Sources;
|
|
710
|
-
const sources = parsePrologList(sourcesStr);
|
|
711
|
-
for (const from of sources) {
|
|
712
|
-
existing.push({ type: relType, from, to: entityId });
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
catch (e) {
|
|
718
|
-
// Best-effort: if we can't read existing relationships, proceed without them.
|
|
719
|
-
// This preserves backward compatibility and avoids breaking mocked tests.
|
|
720
|
-
}
|
|
721
|
-
return existing;
|
|
722
|
-
}
|
|
723
|
-
function parsePrologList(listStr) {
|
|
724
|
-
const trimmed = listStr.trim();
|
|
725
|
-
if (trimmed === "[]")
|
|
726
|
-
return [];
|
|
727
|
-
const match = trimmed.match(/^\[(.*)\]$/s);
|
|
728
|
-
if (!match || !match[1])
|
|
729
|
-
return [];
|
|
730
|
-
const content = match[1];
|
|
731
|
-
const items = [];
|
|
732
|
-
let depth = 0;
|
|
733
|
-
let current = "";
|
|
734
|
-
for (const char of content) {
|
|
735
|
-
if (char === "[")
|
|
736
|
-
depth++;
|
|
737
|
-
if (char === "]")
|
|
738
|
-
depth--;
|
|
739
|
-
if (char === "," && depth === 0) {
|
|
740
|
-
items.push(current.trim().replace(/^'|'$/g, ""));
|
|
741
|
-
current = "";
|
|
742
|
-
continue;
|
|
743
|
-
}
|
|
744
|
-
current += char;
|
|
745
|
-
}
|
|
746
|
-
if (current.trim()) {
|
|
747
|
-
items.push(current.trim().replace(/^'|'$/g, ""));
|
|
748
|
-
}
|
|
749
|
-
return items;
|
|
750
|
-
}
|
|
751
|
-
/**
|
|
752
|
-
* Record audit entry for a successfully committed entity mutation.
|
|
753
|
-
* Called only after the RDF transaction succeeds.
|
|
754
|
-
* implements REQ-011
|
|
755
|
-
*/
|
|
756
|
-
async function recordEntityAudit(prolog, changeKind, type, entity) {
|
|
757
|
-
const props = buildPropertyList(entity);
|
|
758
|
-
const result = await prolog.query(`kb_log_entity_upsert(${changeKind}, ${type}, ${props})`);
|
|
759
|
-
if (!result.success) {
|
|
760
|
-
throw new Error(`Failed to record audit entry for ${String(entity.id)}: ${result.error || "Unknown error"}`);
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
/**
|
|
764
|
-
* Record audit entry for a successfully committed relationship mutation.
|
|
765
|
-
* Called only after the RDF transaction succeeds.
|
|
766
|
-
* implements REQ-011
|
|
767
|
-
*/
|
|
768
|
-
async function recordRelationshipAudit(prolog, rel) {
|
|
769
|
-
const relType = rel.type;
|
|
770
|
-
const from = rel.from;
|
|
771
|
-
const to = rel.to;
|
|
772
|
-
const metadata = buildRelationshipMetadata(rel);
|
|
773
|
-
const result = await prolog.query(`kb_log_relationship_upsert(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
|
|
774
|
-
if (!result.success) {
|
|
775
|
-
throw new Error(`Failed to record relationship audit entry ${from}->${to}: ${result.error || "Unknown error"}`);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
/**
|
|
779
|
-
* Format upsert error message for user-facing display.
|
|
780
|
-
* Removes raw transaction goals and extracts meaningful contradiction details.
|
|
781
|
-
* implements REQ-011
|
|
782
|
-
*/
|
|
783
|
-
function formatUpsertError(entityId, rawError) {
|
|
784
|
-
if (!rawError) {
|
|
785
|
-
return `Failed to upsert entity ${entityId}: Unknown error`;
|
|
786
|
-
}
|
|
787
|
-
const invalidRelationshipError = formatInvalidRelationshipError(rawError);
|
|
788
|
-
if (invalidRelationshipError !== null) {
|
|
789
|
-
return `Failed to upsert entity ${entityId}: ${invalidRelationshipError}`;
|
|
790
|
-
}
|
|
791
|
-
// Check for contradiction error - Prolog returns kb_contradiction([...]) term
|
|
792
|
-
// Try to extract readable details from the term
|
|
793
|
-
const contradictionMatch = rawError.match(/kb_contradiction\(\s*\[([^\]]+)\]\s*\)/);
|
|
794
|
-
if (contradictionMatch) {
|
|
795
|
-
// Extract individual conflict details from the list
|
|
796
|
-
const details = contradictionMatch[1];
|
|
797
|
-
if (!details) {
|
|
798
|
-
return `Contradiction detected for entity ${entityId}: This requirement conflicts with existing requirements. Add a supersedes relationship to the conflicting requirement, or deprecate the old requirement before creating the new one.`;
|
|
799
|
-
}
|
|
800
|
-
// Parse out readable parts - each entry is like 'Reason'-'ReqId'
|
|
801
|
-
const conflicts = [];
|
|
802
|
-
const conflictRegex = /'([^']+)'-'([^']+)'/g;
|
|
803
|
-
let execResult = conflictRegex.exec(details);
|
|
804
|
-
while (execResult !== null) {
|
|
805
|
-
const reason = execResult[1];
|
|
806
|
-
const otherReq = execResult[2];
|
|
807
|
-
if (reason !== undefined && otherReq !== undefined) {
|
|
808
|
-
conflicts.push(` - Conflicts with ${otherReq}: ${reason}`);
|
|
809
|
-
}
|
|
810
|
-
execResult = conflictRegex.exec(details);
|
|
811
|
-
}
|
|
812
|
-
if (conflicts.length > 0) {
|
|
813
|
-
const uniqueConflicts = [...new Set(conflicts)];
|
|
814
|
-
return `Contradiction detected for requirement ${entityId}:\n${uniqueConflicts.join("\n")}\n\nTo resolve:\n 1. Add a supersedes relationship from the new requirement to the conflicting one, OR\n 2. Deprecate the conflicting requirement before creating the new one.`;
|
|
815
|
-
}
|
|
816
|
-
return `Contradiction detected for entity ${entityId}: This requirement conflicts with existing requirements. Add a supersedes relationship to the conflicting requirement, or deprecate the old requirement before creating the new one.`;
|
|
817
|
-
}
|
|
818
|
-
// Check for RDF transaction error
|
|
819
|
-
if (rawError.includes("rdf_transaction")) {
|
|
820
|
-
return `Failed to upsert entity ${entityId}: Transaction failed`;
|
|
821
|
-
}
|
|
822
|
-
// Default: return cleaned error without raw goal
|
|
823
|
-
return `Failed to upsert entity ${entityId}: ${rawError}`;
|
|
824
|
-
}
|