kibi-mcp 0.16.0 → 0.17.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.
@@ -19,11 +19,15 @@ import path from "node:path";
19
19
  */
20
20
  import Ajv from "ajv";
21
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";
22
23
  import entitySchema from "kibi-cli/schemas/entity";
23
24
  import relationshipSchema from "kibi-cli/schemas/relationship";
24
25
  import { Project, ScriptKind } from "ts-morph";
25
26
  import { isMcpDebugEnabled } from "../env.js";
27
+ import { analyzeSemanticAdvisorInput } from "../semantic-advisor/analyze-prose.js";
26
28
  import { refreshCoordinatesForSymbolId } from "./symbols.js";
29
+ import { attachedBranchKbPath, updateAttachedBranchStamp, } from "../server/session.js";
30
+ import { readBranchKbStamp } from "../server/kb-freshness.js";
27
31
  let refreshCoordinatesForSymbolIdImpl = refreshCoordinatesForSymbolId;
28
32
  const ajv = new Ajv({ strict: false });
29
33
  const entitySchemaRecord = entitySchema;
@@ -46,79 +50,164 @@ const validateEntity = ajv.compile({
46
50
  "legacy-link",
47
51
  ],
48
52
  },
53
+ symbol_role: {
54
+ type: "string",
55
+ enum: [...SYMBOL_ROLES],
56
+ },
49
57
  },
50
58
  });
51
59
  const validateRelationship = ajv.compile(relationshipSchema);
52
- const TRACEABILITY_RELATIONSHIP_TYPES = new Set([
53
- "implements",
54
- "covered_by",
55
- "executable_for",
56
- ]);
57
- const ALLOWED_GRANULARITY_REASONS = new Set([
58
- "config-artifact",
59
- "module-level-behavior",
60
- "extractor-miss",
61
- "legacy-link",
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"],
62
67
  ]);
63
- /**
64
- * Handle kb.upsert tool calls
65
- * Accepts { type, id, properties } — the flat format matching the tool schema.
66
- * Validates the assembled entity against JSON Schema before Prolog writes.
67
- * implements REQ-002, REQ-011
68
- */
69
- export async function handleKbUpsert(prolog, args) {
70
- const { type, id, properties, relationships = [] } = args;
71
- if (!type || !id) {
72
- throw new Error("'type' and 'id' are required for upsert");
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)}.`;
73
77
  }
74
- // Assemble full entity from flat args + properties
75
- const entity = {
76
- id,
77
- type,
78
- ...properties,
79
- };
80
- // Fill in defaults for optional required fields
81
- if (!entity.created_at) {
82
- entity.created_at = new Date().toISOString();
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)}.`;
83
82
  }
84
- if (!entity.updated_at) {
85
- entity.updated_at = new Date().toISOString();
83
+ if (typeof value === "string") {
84
+ return `Use value_type: "string" plus value_string: ${JSON.stringify(value)}.`;
86
85
  }
87
- if (!entity.source) {
88
- entity.source = "mcp://kibi/upsert";
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;
89
113
  }
90
- const entities = [entity];
91
- // Validate all entities
92
- for (let i = 0; i < entities.length; i++) {
93
- const ent = entities[i];
94
- if (!validateEntity(ent)) {
95
- const errors = validateEntity.errors || [];
96
- const errorMessages = errors
97
- .map((e) => `${e.instancePath || "root"}: ${e.message}`)
98
- .join("; ");
99
- throw new Error(`Entity validation failed: ${errorMessages}`);
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.");
100
128
  }
129
+ return hints;
101
130
  }
102
- // Validate all relationships
103
- for (let i = 0; i < relationships.length; i++) {
104
- const rel = relationships[i];
105
- if (!validateRelationship(rel)) {
106
- const errors = validateRelationship.errors || [];
107
- const errorMessages = errors
108
- .map((e) => `${e.instancePath || "root"}: ${e.message}`)
109
- .join("; ");
110
- throw new Error(`Relationship validation failed at index ${i}: ${errorMessages}`);
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.`);
111
156
  }
112
157
  }
113
- validateRelationshipSources(id, relationships);
114
- validateSymbolGranularity(entity, relationships);
115
- // Validate strict-lane fact_kind pairing for constrains/requires_property
116
- // implements REQ-011
117
- await validateStrictLanePairing(prolog, relationships);
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
+ */
179
+ 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;
118
189
  let created = 0;
119
190
  let updated = 0;
120
191
  let relationshipsCreated = 0;
121
192
  try {
193
+ if ((args.relationships === undefined ||
194
+ (Array.isArray(args.relationships) && args.relationships.length === 0)) &&
195
+ args.id) {
196
+ // Preserve relationships on updates when the request omits relationships
197
+ // OR provides an empty array. This avoids accidental edge deletion by
198
+ // clients that always serialize `relationships: []` for partial updates.
199
+ // For creates, this remains cheap because existence is checked first.
200
+ const existsResult = await prolog.query(`once(kb_entity('${escapeAtom(args.id)}', _, _))`);
201
+ if (existsResult.success) {
202
+ const existing = await fetchExistingRelationships(prolog, args.id);
203
+ if (existing.length > 0) {
204
+ effectiveRelationships = existing;
205
+ }
206
+ }
207
+ }
208
+ // Validate strict-lane fact_kind pairing for constrains/requires_property
209
+ // implements REQ-011
210
+ await validateStrictLanePairing(prolog, effectiveRelationships);
122
211
  // Process entities
123
212
  for (const entity of entities) {
124
213
  const id = entity.id;
@@ -131,7 +220,7 @@ export async function handleKbUpsert(prolog, args) {
131
220
  const props = buildPropertyList(entity);
132
221
  // Build relationship goals
133
222
  const relationshipGoals = [];
134
- for (const rel of relationships) {
223
+ for (const rel of effectiveRelationships) {
135
224
  const relType = rel.type;
136
225
  const from = rel.from;
137
226
  const to = rel.to;
@@ -172,7 +261,7 @@ export async function handleKbUpsert(prolog, args) {
172
261
  throw new Error(formattedError);
173
262
  }
174
263
  await recordEntityAudit(prolog, isUpdate ? "updated" : "created", type, entity);
175
- for (const rel of relationships) {
264
+ for (const rel of effectiveRelationships) {
176
265
  await recordRelationshipAudit(prolog, rel);
177
266
  }
178
267
  // Update counters
@@ -182,7 +271,7 @@ export async function handleKbUpsert(prolog, args) {
182
271
  else {
183
272
  created++;
184
273
  }
185
- relationshipsCreated += relationships.length;
274
+ relationshipsCreated += effectiveRelationships.length;
186
275
  }
187
276
  // Save KB to disk after all entities/relationships are written to ensure
188
277
  // durability across process restarts.
@@ -191,28 +280,43 @@ export async function handleKbUpsert(prolog, args) {
191
280
  if (!saveResult.success) {
192
281
  throw new Error(`Failed to save KB after upsert: ${saveResult.error || "Unknown error"}`);
193
282
  }
283
+ // Update session stamp so kb_check (and other tools) don't trigger a
284
+ // stale-detach refresh that would unload the just-saved runtime state.
285
+ if (attachedBranchKbPath) {
286
+ try {
287
+ const freshStamp = await readBranchKbStamp(attachedBranchKbPath);
288
+ updateAttachedBranchStamp(freshStamp);
289
+ }
290
+ catch {
291
+ // Non-fatal: stamp update is best-effort to avoid spurious refresh.
292
+ }
293
+ }
194
294
  if (type === "symbol") {
195
295
  try {
196
- await refreshCoordinatesForSymbolIdImpl(id);
296
+ await refreshCoordinatesForSymbolIdImpl(entity.id);
197
297
  }
198
298
  catch (error) {
199
299
  const message = error instanceof Error ? error.message : String(error);
200
300
  if (isMcpDebugEnabled()) {
201
- console.warn(`[KIBI-MCP] Symbol coordinate auto-refresh failed for ${id}: ${message}`);
301
+ console.warn(`[KIBI-MCP] Symbol coordinate auto-refresh failed for ${String(entity.id)}: ${message}`);
202
302
  }
203
303
  }
204
304
  }
305
+ // Check for scenario-coverage guidance
306
+ const coverageWarnings = await checkScenarioCoverageGuidance(prolog, relationships, type, entity.id);
205
307
  return {
206
308
  content: [
207
309
  {
208
310
  type: "text",
209
- text: `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
311
+ text: `Upserted ${String(entity.id)} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
210
312
  },
211
313
  ],
212
314
  structuredContent: {
213
315
  created,
214
316
  updated,
215
317
  relationships_created: relationshipsCreated,
318
+ warnings: [...semanticAdvisor.warnings, ...coverageWarnings],
319
+ semanticAdvisor: semanticAdvisor.receipt,
216
320
  },
217
321
  };
218
322
  }
@@ -221,6 +325,45 @@ export async function handleKbUpsert(prolog, args) {
221
325
  throw new Error(`Upsert execution failed: ${message}`);
222
326
  }
223
327
  }
328
+ export function validateKbUpsertArgs(args) {
329
+ const { type, id, properties, relationships = [] } = args;
330
+ if (!type || !id) {
331
+ throw new Error("'type' and 'id' are required for upsert");
332
+ }
333
+ const entity = {
334
+ id,
335
+ type,
336
+ ...properties,
337
+ };
338
+ if (!entity.created_at) {
339
+ entity.created_at = new Date().toISOString();
340
+ }
341
+ if (!entity.updated_at) {
342
+ entity.updated_at = new Date().toISOString();
343
+ }
344
+ if (!entity.source) {
345
+ entity.source = "mcp://kibi/upsert";
346
+ }
347
+ if (!validateEntity(entity)) {
348
+ const errors = validateEntity.errors || [];
349
+ const errorMessages = formatEntityValidationErrors(entity, errors);
350
+ throw new Error(`Entity validation failed: ${errorMessages}`);
351
+ }
352
+ validateFactModelingShape(entity);
353
+ for (let i = 0; i < relationships.length; i++) {
354
+ const rel = relationships[i];
355
+ if (!validateRelationship(rel)) {
356
+ const errors = validateRelationship.errors || [];
357
+ const errorMessages = errors
358
+ .map((e) => `${e.instancePath || "root"}: ${e.message}`)
359
+ .join("; ");
360
+ throw new Error(`Relationship validation failed at index ${i}: ${errorMessages}`);
361
+ }
362
+ }
363
+ validateRelationshipSources(id, relationships);
364
+ validateSymbolGranularity(entity, relationships);
365
+ return { entity, relationships };
366
+ }
224
367
  function chooseScriptKind(filePath) {
225
368
  const lower = filePath.toLowerCase();
226
369
  if (lower.endsWith(".tsx"))
@@ -235,58 +378,69 @@ function chooseScriptKind(filePath) {
235
378
  return ScriptKind.JS;
236
379
  }
237
380
  function hasTraceabilityRelationship(relationships) {
238
- return relationships.some((relationship) => typeof relationship.type === "string" &&
239
- TRACEABILITY_RELATIONSHIP_TYPES.has(relationship.type));
381
+ return relationships.some((relationship) => isTraceabilityRelationshipType(relationship.type));
240
382
  }
241
383
  function hasAllowedGranularityReason(entity) {
242
- const reason = entity.granularity_reason;
243
- return typeof reason === "string" && ALLOWED_GRANULARITY_REASONS.has(reason);
384
+ return isAllowedGranularityReason(entity.granularity_reason);
244
385
  }
245
- function collectNarrowExportNames(filePath, content) {
386
+ function createSymbolCandidate(name, kind) {
387
+ return {
388
+ name,
389
+ kind,
390
+ role: inferSymbolRole(kind),
391
+ };
392
+ }
393
+ function collectGranularSymbolCandidates(filePath, content) {
246
394
  const project = new Project({ skipAddingFilesFromTsConfig: true });
247
395
  const sourceFile = project.createSourceFile(`${filePath}::granularity`, content, {
248
396
  overwrite: true,
249
397
  scriptKind: chooseScriptKind(filePath),
250
398
  });
251
- const names = new Set();
399
+ const candidates = [];
252
400
  const methodNameCounts = new Map();
401
+ const bareMethodCandidates = new Map();
253
402
  for (const fn of sourceFile.getFunctions()) {
254
403
  if (fn.isExported()) {
255
404
  const name = fn.getName();
256
405
  if (name)
257
- names.add(name);
406
+ candidates.push(createSymbolCandidate(name, "function"));
258
407
  }
259
408
  }
260
409
  for (const cls of sourceFile.getClasses()) {
261
410
  if (cls.isExported()) {
262
411
  const name = cls.getName();
263
412
  if (name)
264
- names.add(name);
413
+ candidates.push(createSymbolCandidate(name, "class"));
265
414
  for (const method of cls.getMethods()) {
266
415
  const methodName = method.getName();
267
- if (name)
268
- names.add(`${name}.${methodName}`);
416
+ if (name) {
417
+ candidates.push(createSymbolCandidate(`${name}.${methodName}`, "method"));
418
+ }
419
+ bareMethodCandidates.set(methodName, createSymbolCandidate(methodName, "method"));
269
420
  methodNameCounts.set(methodName, (methodNameCounts.get(methodName) ?? 0) + 1);
270
421
  }
271
422
  }
272
423
  }
273
424
  for (const [methodName, count] of methodNameCounts) {
274
- if (count === 1)
275
- names.add(methodName);
425
+ const candidate = bareMethodCandidates.get(methodName);
426
+ if (count === 1 && candidate)
427
+ candidates.push(candidate);
276
428
  }
277
429
  for (const iface of sourceFile.getInterfaces()) {
278
- if (iface.isExported())
279
- names.add(iface.getName());
430
+ if (iface.isExported()) {
431
+ candidates.push(createSymbolCandidate(iface.getName(), "interface"));
432
+ }
280
433
  }
281
434
  for (const alias of sourceFile.getTypeAliases()) {
282
- if (alias.isExported())
283
- names.add(alias.getName());
435
+ if (alias.isExported()) {
436
+ candidates.push(createSymbolCandidate(alias.getName(), "type"));
437
+ }
284
438
  }
285
439
  for (const en of sourceFile.getEnums()) {
286
440
  if (en.isExported())
287
- names.add(en.getName());
441
+ candidates.push(createSymbolCandidate(en.getName(), "enum"));
288
442
  }
289
- return [...names].sort();
443
+ return candidates.sort((a, b) => a.name.localeCompare(b.name));
290
444
  }
291
445
  function validateSymbolGranularity(entity, relationships) {
292
446
  if (entity.type !== "symbol")
@@ -304,12 +458,29 @@ function validateSymbolGranularity(entity, relationships) {
304
458
  : path.resolve(process.cwd(), entity.sourceFile);
305
459
  if (!existsSync(sourcePath))
306
460
  return;
307
- const narrowNames = collectNarrowExportNames(entity.sourceFile, readFileSync(sourcePath, "utf8"));
308
- if (narrowNames.length === 0)
461
+ const candidates = collectGranularSymbolCandidates(entity.sourceFile, readFileSync(sourcePath, "utf8"));
462
+ const candidateNames = [
463
+ ...new Set(candidates.map((candidate) => candidate.name)),
464
+ ];
465
+ if (candidateNames.includes(entity.title))
309
466
  return;
310
- if (narrowNames.includes(entity.title))
467
+ const behavioralNames = getBehavioralSymbolNames(candidates);
468
+ if (behavioralNames.length === 0)
311
469
  return;
312
- throw new Error(`Symbol ${String(entity.id)} links ${entity.sourceFile} coarsely while granular symbols are available: ${narrowNames.join(", ")}. Move relationships to the narrow symbol or set granularity_reason to config-artifact, module-level-behavior, extractor-miss, or legacy-link.`);
470
+ const nonBehavioralNames = getNonBehavioralSymbolNames(candidates);
471
+ const maxNamesInMessage = 10;
472
+ const shownBehavioral = behavioralNames.slice(0, maxNamesInMessage);
473
+ const hiddenBehavioralCount = behavioralNames.length - shownBehavioral.length;
474
+ const behavioralList = shownBehavioral.join(", ") +
475
+ (hiddenBehavioralCount > 0 ? `, and ${hiddenBehavioralCount} more` : "");
476
+ const shownNonBehavioral = nonBehavioralNames.slice(0, maxNamesInMessage);
477
+ const hiddenNonBehavioralCount = nonBehavioralNames.length - shownNonBehavioral.length;
478
+ const nonBehavioralList = shownNonBehavioral.join(", ") +
479
+ (hiddenNonBehavioralCount > 0 ? `, and ${hiddenNonBehavioralCount} more` : "");
480
+ const ignoredSymbolsMessage = nonBehavioralNames.length > 0
481
+ ? ` Non-behavioral symbols in the file were ignored for this decision: ${nonBehavioralList}.`
482
+ : "";
483
+ 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}`);
313
484
  }
314
485
  export const __test__ = {
315
486
  // implements REQ-vscode-traceability
@@ -333,6 +504,7 @@ function buildPropertyList(entity) {
333
504
  "owner",
334
505
  "priority",
335
506
  "severity",
507
+ "symbol_role",
336
508
  // Typed fact enum fields must be atoms for Prolog validation
337
509
  "fact_kind",
338
510
  "operator",
@@ -446,6 +618,103 @@ async function validateStrictLanePairing(prolog, relationships) {
446
618
  }
447
619
  }
448
620
  }
621
+ /**
622
+ * Check for scenario-coverage guidance when verified_by is added to a
623
+ * requirement that already has scenarios (specified_by relationships).
624
+ * Returns non-blocking warnings; never throws.
625
+ */
626
+ async function checkScenarioCoverageGuidance(prolog, relationships, entityType, entityId) {
627
+ const warnings = [];
628
+ if (entityType !== "req")
629
+ return warnings;
630
+ try {
631
+ for (const rel of relationships) {
632
+ const relType = rel.type;
633
+ if (relType !== "verified_by")
634
+ continue;
635
+ // Check if this requirement has scenarios
636
+ const scenarioQuery = `kb_relationship(specified_by, '${escapeAtom(entityId)}', ScenarioId)`;
637
+ const scenarioResult = await prolog.query(`once(${scenarioQuery})`);
638
+ if (scenarioResult.success) {
639
+ 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.`);
640
+ break; // One warning per entity is enough
641
+ }
642
+ }
643
+ }
644
+ catch {
645
+ // Non-blocking: never fail the upsert
646
+ }
647
+ return warnings;
648
+ }
649
+ /**
650
+ * Query existing relationships for an entity from the live KB.
651
+ * Used to preserve relationships when the upsert request omits the
652
+ * relationships field (entity-only property update).
653
+ */
654
+ async function fetchExistingRelationships(prolog, entityId) {
655
+ const relTypes = [
656
+ "depends_on", "specified_by", "verified_by", "validates",
657
+ "implements", "covered_by", "executable_for", "constrained_by",
658
+ "constrains", "requires_property", "requires_predicate",
659
+ "guards", "publishes", "consumes", "supersedes", "relates_to",
660
+ ];
661
+ const existing = [];
662
+ try {
663
+ for (const relType of relTypes) {
664
+ const forwardGoal = `findall(To, kb_relationship(${relType}, '${escapeAtom(entityId)}', To), Targets)`;
665
+ const forwardResult = await prolog.query(forwardGoal);
666
+ if (forwardResult.success && forwardResult.bindings.Targets) {
667
+ const targetsStr = forwardResult.bindings.Targets;
668
+ const targets = parsePrologList(targetsStr);
669
+ for (const to of targets) {
670
+ existing.push({ type: relType, from: entityId, to });
671
+ }
672
+ }
673
+ const reverseGoal = `findall(From, kb_relationship(${relType}, From, '${escapeAtom(entityId)}'), Sources)`;
674
+ const reverseResult = await prolog.query(reverseGoal);
675
+ if (reverseResult.success && reverseResult.bindings.Sources) {
676
+ const sourcesStr = reverseResult.bindings.Sources;
677
+ const sources = parsePrologList(sourcesStr);
678
+ for (const from of sources) {
679
+ existing.push({ type: relType, from, to: entityId });
680
+ }
681
+ }
682
+ }
683
+ }
684
+ catch (e) {
685
+ // Best-effort: if we can't read existing relationships, proceed without them.
686
+ // This preserves backward compatibility and avoids breaking mocked tests.
687
+ }
688
+ return existing;
689
+ }
690
+ function parsePrologList(listStr) {
691
+ const trimmed = listStr.trim();
692
+ if (trimmed === "[]")
693
+ return [];
694
+ const match = trimmed.match(/^\[(.*)\]$/s);
695
+ if (!match || !match[1])
696
+ return [];
697
+ const content = match[1];
698
+ const items = [];
699
+ let depth = 0;
700
+ let current = "";
701
+ for (const char of content) {
702
+ if (char === "[")
703
+ depth++;
704
+ if (char === "]")
705
+ depth--;
706
+ if (char === "," && depth === 0) {
707
+ items.push(current.trim().replace(/^'|'$/g, ""));
708
+ current = "";
709
+ continue;
710
+ }
711
+ current += char;
712
+ }
713
+ if (current.trim()) {
714
+ items.push(current.trim().replace(/^'|'$/g, ""));
715
+ }
716
+ return items;
717
+ }
449
718
  /**
450
719
  * Record audit entry for a successfully committed entity mutation.
451
720
  * Called only after the RDF transaction succeeds.
@@ -0,0 +1,43 @@
1
+ import { analyzeSemanticAdvisorInput } from "../semantic-advisor/analyze-prose.js";
2
+ import { validateKbUpsertArgs } from "./upsert.js";
3
+ export async function handleKbValidateUpsert(args) {
4
+ try {
5
+ const { entity } = validateKbUpsertArgs(args);
6
+ const semanticAdvisor = analyzeSemanticAdvisorInput({
7
+ payload: { ...args },
8
+ });
9
+ return {
10
+ content: [
11
+ {
12
+ type: "text",
13
+ text: "kb_validate_upsert: payload is valid for kb_upsert preflight checks. No mutation was performed.",
14
+ },
15
+ ],
16
+ structuredContent: {
17
+ valid: true,
18
+ errors: [],
19
+ warnings: semanticAdvisor.warnings,
20
+ semanticAdvisor: semanticAdvisor.receipt,
21
+ normalizedPreview: entity,
22
+ },
23
+ };
24
+ }
25
+ catch (error) {
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ return {
28
+ content: [
29
+ {
30
+ type: "text",
31
+ text: `kb_validate_upsert: payload is invalid. ${message}`,
32
+ },
33
+ ],
34
+ structuredContent: {
35
+ valid: false,
36
+ errors: [message],
37
+ warnings: [],
38
+ semanticAdvisor: null,
39
+ normalizedPreview: null,
40
+ },
41
+ };
42
+ }
43
+ }