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