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.
- package/dist/semantic-advisor/analyze-prose.js +1367 -0
- package/dist/semantic-advisor/prose-coverage-evaluator.js +66 -0
- package/dist/semantic-advisor/types.js +1 -0
- package/dist/server/kb-freshness.js +88 -0
- package/dist/server/session.js +65 -4
- package/dist/server/tools.js +16 -0
- package/dist/tools/check.js +2 -0
- package/dist/tools/model-requirement.js +10 -0
- package/dist/tools/semantic-advisor.js +42 -0
- package/dist/tools/sparql.js +96 -0
- package/dist/tools/suggest-predicates.js +1905 -9
- package/dist/tools/upsert.js +354 -85
- package/dist/tools/validate-upsert.js +43 -0
- package/dist/tools-config.js +109 -7
- package/package.json +7 -4
package/dist/tools/upsert.js
CHANGED
|
@@ -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
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
]
|
|
57
|
-
|
|
58
|
-
"
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 (
|
|
85
|
-
|
|
83
|
+
if (typeof value === "string") {
|
|
84
|
+
return `Use value_type: "string" plus value_string: ${JSON.stringify(value)}.`;
|
|
86
85
|
}
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
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 +=
|
|
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) =>
|
|
239
|
-
TRACEABILITY_RELATIONSHIP_TYPES.has(relationship.type));
|
|
381
|
+
return relationships.some((relationship) => isTraceabilityRelationshipType(relationship.type));
|
|
240
382
|
}
|
|
241
383
|
function hasAllowedGranularityReason(entity) {
|
|
242
|
-
|
|
243
|
-
return typeof reason === "string" && ALLOWED_GRANULARITY_REASONS.has(reason);
|
|
384
|
+
return isAllowedGranularityReason(entity.granularity_reason);
|
|
244
385
|
}
|
|
245
|
-
function
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
413
|
+
candidates.push(createSymbolCandidate(name, "class"));
|
|
265
414
|
for (const method of cls.getMethods()) {
|
|
266
415
|
const methodName = method.getName();
|
|
267
|
-
if (name)
|
|
268
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
+
candidates.push(createSymbolCandidate(en.getName(), "enum"));
|
|
288
442
|
}
|
|
289
|
-
return
|
|
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
|
|
308
|
-
|
|
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
|
-
|
|
467
|
+
const behavioralNames = getBehavioralSymbolNames(candidates);
|
|
468
|
+
if (behavioralNames.length === 0)
|
|
311
469
|
return;
|
|
312
|
-
|
|
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
|
+
}
|