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.
Files changed (36) hide show
  1. package/bin/kibi-mcp +1 -1
  2. package/dist/runtime/mcp-runtime.js +43 -0
  3. package/dist/server/docs.js +4 -4
  4. package/dist/server/json-schema-to-zod.js +73 -2
  5. package/dist/server/session.js +33 -15
  6. package/dist/server/tool-registration.js +35 -62
  7. package/dist/server/tools-runtime.js +52 -2
  8. package/dist/server/tools.js +19 -5
  9. package/dist/server/transport.js +9 -5
  10. package/dist/tools/autopilot-generate.js +37 -646
  11. package/dist/tools/autopilot-test-compat.js +88 -0
  12. package/dist/tools/autopilot-test-parsers.js +120 -0
  13. package/dist/tools/check.js +13 -106
  14. package/dist/tools/coverage.js +11 -25
  15. package/dist/tools/delete.js +5 -112
  16. package/dist/tools/discovery-adapter.js +23 -0
  17. package/dist/tools/entity-query.js +1 -111
  18. package/dist/tools/find-gaps.js +11 -26
  19. package/dist/tools/graph.js +11 -24
  20. package/dist/tools/model-requirement.js +8 -347
  21. package/dist/tools/mutation-context.js +28 -0
  22. package/dist/tools/query.js +5 -50
  23. package/dist/tools/relationship-validation.js +9 -116
  24. package/dist/tools/search.js +4 -37
  25. package/dist/tools/semantic-advisor.js +2 -40
  26. package/dist/tools/skills.js +17 -57
  27. package/dist/tools/sparql.js +12 -93
  28. package/dist/tools/status.js +5 -19
  29. package/dist/tools/suggest-predicates.js +11 -2448
  30. package/dist/tools/upsert.js +21 -816
  31. package/dist/tools/validate-upsert.js +12 -48
  32. package/dist/tools-config.js +75 -802
  33. package/package.json +4 -3
  34. package/dist/semantic-advisor/analyze-prose.js +0 -1392
  35. package/dist/semantic-advisor/prose-coverage-evaluator.js +0 -66
  36. package/dist/semantic-advisor/types.js +0 -1
@@ -1,824 +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 { 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
- const { entity, relationships } = validateKbUpsertArgs(args);
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
- const { type, id, properties, relationships = [] } = args;
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
- // implements REQ-vscode-traceability
505
- setRefreshCoordinatesForSymbolIdForTests(fn) {
506
- 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
+ });
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
- }