kibi-mcp 0.3.3 → 0.5.0
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/diagnostics.js +28 -0
- package/dist/server/docs.js +22 -6
- package/dist/server/tools.js +29 -1
- package/dist/tools/check.js +27 -30
- package/dist/tools/core-module.js +85 -0
- package/dist/tools/coverage.js +28 -0
- package/dist/tools/entity-query.js +111 -0
- package/dist/tools/find-gaps.js +29 -0
- package/dist/tools/graph.js +27 -0
- package/dist/tools/prolog-list.js +1 -43
- package/dist/tools/query.js +4 -109
- package/dist/tools/search.js +37 -0
- package/dist/tools/status.js +20 -0
- package/dist/tools/symbols.js +4 -1
- package/dist/tools/upsert.js +151 -34
- package/dist/tools-config.js +169 -2
- package/package.json +3 -3
package/dist/tools/query.js
CHANGED
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export
|
|
3
|
-
"req",
|
|
4
|
-
"scenario",
|
|
5
|
-
"test",
|
|
6
|
-
"adr",
|
|
7
|
-
"flag",
|
|
8
|
-
"event",
|
|
9
|
-
"symbol",
|
|
10
|
-
"fact",
|
|
11
|
-
];
|
|
1
|
+
import { loadEntities, paginateResults, } from "./entity-query.js";
|
|
2
|
+
export { VALID_ENTITY_TYPES } from "./entity-query.js";
|
|
12
3
|
/**
|
|
13
4
|
* Handle kb.query tool calls
|
|
14
5
|
* Reuses query logic from CLI command
|
|
@@ -16,73 +7,8 @@ export const VALID_ENTITY_TYPES = [
|
|
|
16
7
|
export async function handleKbQuery(prolog, args) {
|
|
17
8
|
const { type, id, tags, sourceFile, limit = 100, offset = 0 } = args;
|
|
18
9
|
try {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (type) {
|
|
22
|
-
if (!VALID_ENTITY_TYPES.includes(type)) {
|
|
23
|
-
throw new Error(`Invalid type '${type}'. Valid types: ${VALID_ENTITY_TYPES.join(", ")}. Use a single type value, or omit this parameter to query all entities.`);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
// Build Prolog query
|
|
27
|
-
let goal;
|
|
28
|
-
if (sourceFile) {
|
|
29
|
-
const safeSource = escapeAtomContent(sourceFile);
|
|
30
|
-
if (type) {
|
|
31
|
-
const safeType = escapeAtomContent(type);
|
|
32
|
-
goal = `findall([Id,'${safeType}',Props], (kb_entities_by_source('${safeSource}', SourceIds), member(Id, SourceIds), kb_entity(Id, '${safeType}', Props)), Results)`;
|
|
33
|
-
}
|
|
34
|
-
else {
|
|
35
|
-
goal = `findall([Id,Type,Props], (kb_entities_by_source('${safeSource}', SourceIds), member(Id, SourceIds), kb_entity(Id, Type, Props)), Results)`;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
else if (id && type) {
|
|
39
|
-
const safeId = escapeAtomContent(id);
|
|
40
|
-
const safeType = escapeAtomContent(type);
|
|
41
|
-
goal = `findall(['${safeId}','${safeType}',Props], kb_entity('${safeId}', '${safeType}', Props), Results)`;
|
|
42
|
-
}
|
|
43
|
-
else if (id) {
|
|
44
|
-
const safeId = escapeAtomContent(id);
|
|
45
|
-
goal = `findall(['${safeId}',Type,Props], kb_entity('${safeId}', Type, Props), Results)`;
|
|
46
|
-
}
|
|
47
|
-
else if (tags && tags.length > 0) {
|
|
48
|
-
// JS-side fallback until REQ-mcp-tag-filtering-server-side is implemented.
|
|
49
|
-
if (type) {
|
|
50
|
-
const safeType = escapeAtomContent(type);
|
|
51
|
-
goal = `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
|
|
52
|
-
}
|
|
53
|
-
else {
|
|
54
|
-
goal = "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)";
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
else if (type) {
|
|
58
|
-
const safeType = escapeAtomContent(type);
|
|
59
|
-
goal = `findall([Id,'${safeType}',Props], kb_entity(Id, '${safeType}', Props), Results)`;
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
goal = "findall([Id,Type,Props], kb_entity(Id, Type, Props), Results)";
|
|
63
|
-
}
|
|
64
|
-
const queryResult = await prolog.query(goal);
|
|
65
|
-
if (queryResult.success) {
|
|
66
|
-
if (queryResult.bindings.Results) {
|
|
67
|
-
const entitiesData = parseListOfLists(queryResult.bindings.Results);
|
|
68
|
-
for (const data of entitiesData) {
|
|
69
|
-
const entity = parseEntityFromList(data);
|
|
70
|
-
results.push(entity);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
else if (queryResult.bindings.Result) {
|
|
74
|
-
const entity = parseEntityFromBinding(queryResult.bindings.Result);
|
|
75
|
-
results = [entity];
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
else {
|
|
79
|
-
throw new Error(queryResult.error || "Query failed with unknown error");
|
|
80
|
-
}
|
|
81
|
-
if (tags && tags.length > 0) {
|
|
82
|
-
results = dedupeEntities(results.filter((entity) => hasAnyTag(entity, tags)));
|
|
83
|
-
}
|
|
84
|
-
// Apply pagination
|
|
85
|
-
const paginated = results.slice(offset, offset + limit);
|
|
10
|
+
const results = await loadEntities(prolog, { type, id, tags, sourceFile });
|
|
11
|
+
const paginated = paginateResults(results, limit, offset);
|
|
86
12
|
// Build human-readable text with entity IDs and titles
|
|
87
13
|
let text;
|
|
88
14
|
if (results.length === 0) {
|
|
@@ -118,34 +44,3 @@ export async function handleKbQuery(prolog, args) {
|
|
|
118
44
|
throw new Error(`Query execution failed: ${message}`);
|
|
119
45
|
}
|
|
120
46
|
}
|
|
121
|
-
function hasAnyTag(entity, requestedTags) {
|
|
122
|
-
const expected = new Set(requestedTags.map(normalizeTagValue));
|
|
123
|
-
const rawTags = entity.tags;
|
|
124
|
-
if (!Array.isArray(rawTags) || rawTags.length === 0) {
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
for (const tag of rawTags) {
|
|
128
|
-
if (expected.has(normalizeTagValue(tag))) {
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
function normalizeTagValue(tag) {
|
|
135
|
-
return String(tag).trim();
|
|
136
|
-
}
|
|
137
|
-
function dedupeEntities(entities) {
|
|
138
|
-
const seen = new Set();
|
|
139
|
-
const deduped = [];
|
|
140
|
-
for (const entity of entities) {
|
|
141
|
-
const id = String(entity.id ?? "");
|
|
142
|
-
const type = String(entity.type ?? "");
|
|
143
|
-
const key = `${type}::${id}`;
|
|
144
|
-
if (seen.has(key)) {
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
seen.add(key);
|
|
148
|
-
deduped.push(entity);
|
|
149
|
-
}
|
|
150
|
-
return deduped;
|
|
151
|
-
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { rankEntities } from "kibi-cli/search-ranking";
|
|
2
|
+
import { resolveWorkspaceRoot } from "../workspace.js";
|
|
3
|
+
import { loadEntities, paginateResults, validateEntityType, } from "./entity-query.js";
|
|
4
|
+
// implements REQ-mcp-search-discovery, REQ-002
|
|
5
|
+
export async function handleKbSearch(prolog, args) {
|
|
6
|
+
const { query, type, limit = 20, offset = 0 } = args;
|
|
7
|
+
const trimmedQuery = query.trim();
|
|
8
|
+
if (!trimmedQuery) {
|
|
9
|
+
throw new Error("Search execution failed: query must be a non-empty string");
|
|
10
|
+
}
|
|
11
|
+
validateEntityType(type);
|
|
12
|
+
try {
|
|
13
|
+
const workspaceRoot = resolveWorkspaceRoot();
|
|
14
|
+
const entities = await loadEntities(prolog, { type });
|
|
15
|
+
const matches = await rankEntities(entities, trimmedQuery, workspaceRoot);
|
|
16
|
+
const paginated = paginateResults(matches, limit, offset);
|
|
17
|
+
const text = matches.length === 0
|
|
18
|
+
? `No search results for '${trimmedQuery}'.`
|
|
19
|
+
: `Found ${matches.length} search results for '${trimmedQuery}'. Showing ${paginated.length} (offset ${offset}, limit ${limit}): ${paginated
|
|
20
|
+
.map((match) => `${match.entity.id} [${match.reasons.join(", ")}]`)
|
|
21
|
+
.join(", ")}`;
|
|
22
|
+
return {
|
|
23
|
+
content: [{ type: "text", text }],
|
|
24
|
+
structuredContent: {
|
|
25
|
+
results: paginated,
|
|
26
|
+
count: matches.length,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
if (message.startsWith("Search execution failed:")) {
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`Search execution failed: ${message}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { runJsonModuleQuery } from "./core-module.js";
|
|
2
|
+
// implements REQ-002, REQ-013
|
|
3
|
+
export async function handleKbStatus(prolog, _args) {
|
|
4
|
+
try {
|
|
5
|
+
const payload = await runJsonModuleQuery(prolog, "status.pl", "status:kb_status_json(JsonString)", "Status execution");
|
|
6
|
+
return {
|
|
7
|
+
content: [
|
|
8
|
+
{
|
|
9
|
+
type: "text",
|
|
10
|
+
text: `Branch ${payload.branch} is ${payload.syncState} (snapshot ${payload.snapshotId}, dirty=${payload.dirty})`,
|
|
11
|
+
},
|
|
12
|
+
],
|
|
13
|
+
structuredContent: payload,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
+
throw new Error(`Status execution failed: ${message}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/tools/symbols.js
CHANGED
|
@@ -151,6 +151,7 @@ export async function refreshCoordinatesForSymbolId(symbolId, workspaceRoot = re
|
|
|
151
151
|
return { refreshed, found: true };
|
|
152
152
|
}
|
|
153
153
|
export function resolveManifestPath(workspaceRoot) {
|
|
154
|
+
// implements REQ-002, REQ-013
|
|
154
155
|
const configPath = path.join(workspaceRoot, ".kb", "config.json");
|
|
155
156
|
if (existsSync(configPath)) {
|
|
156
157
|
try {
|
|
@@ -168,7 +169,9 @@ export function resolveManifestPath(workspaceRoot) {
|
|
|
168
169
|
: path.resolve(workspaceRoot, config.symbolsManifest);
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
|
-
catch {
|
|
172
|
+
catch {
|
|
173
|
+
// config file missing or malformed; fall through to defaults
|
|
174
|
+
}
|
|
172
175
|
}
|
|
173
176
|
const candidates = [
|
|
174
177
|
path.join(workspaceRoot, "symbols.yaml"),
|
package/dist/tools/upsert.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
*/
|
|
18
18
|
import Ajv from "ajv";
|
|
19
|
-
import { escapeAtom, toPrologAtom } from "kibi-cli/prolog/codec";
|
|
19
|
+
import { escapeAtom, toPrologAtom, toPrologString, } from "kibi-cli/prolog/codec";
|
|
20
20
|
import entitySchema from "kibi-cli/schemas/entity";
|
|
21
21
|
import relationshipSchema from "kibi-cli/schemas/relationship";
|
|
22
22
|
import { refreshCoordinatesForSymbolId } from "./symbols.js";
|
|
@@ -73,6 +73,10 @@ export async function handleKbUpsert(prolog, args) {
|
|
|
73
73
|
throw new Error(`Relationship validation failed at index ${i}: ${errorMessages}`);
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
+
validateRelationshipSources(id, relationships);
|
|
77
|
+
// Validate strict-lane fact_kind pairing for constrains/requires_property
|
|
78
|
+
// implements REQ-011
|
|
79
|
+
await validateStrictLanePairing(prolog, relationships);
|
|
76
80
|
let created = 0;
|
|
77
81
|
let updated = 0;
|
|
78
82
|
let relationshipsCreated = 0;
|
|
@@ -94,26 +98,44 @@ export async function handleKbUpsert(prolog, args) {
|
|
|
94
98
|
const from = rel.from;
|
|
95
99
|
const to = rel.to;
|
|
96
100
|
const metadata = buildRelationshipMetadata(rel);
|
|
97
|
-
relationshipGoals.push(`
|
|
101
|
+
relationshipGoals.push(`kb_assert_relationship_no_audit(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
|
|
98
102
|
}
|
|
99
103
|
// Build atomic transaction goal wrapping entity + all relationships
|
|
104
|
+
// For requirements, also include contradiction check within the transaction
|
|
100
105
|
// implements REQ-002, REQ-011
|
|
101
106
|
let transactionGoal;
|
|
107
|
+
const needsContradictionCheck = type === "req" && !args._skipContradictionCheck;
|
|
102
108
|
if (relationshipGoals.length === 0) {
|
|
103
109
|
// Simple case: just entity
|
|
104
|
-
|
|
110
|
+
if (needsContradictionCheck) {
|
|
111
|
+
transactionGoal = `rdf_transaction((kb_assert_entity_no_audit(${type}, ${props}), check_req_contradiction('${escapeAtom(id)}')))`;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
transactionGoal = `rdf_transaction((kb_assert_entity_no_audit(${type}, ${props})))`;
|
|
115
|
+
}
|
|
105
116
|
}
|
|
106
117
|
else {
|
|
107
118
|
// Entity + relationships in one transaction
|
|
108
119
|
const goals = [
|
|
109
|
-
`
|
|
120
|
+
`kb_assert_entity_no_audit(${type}, ${props})`,
|
|
110
121
|
...relationshipGoals,
|
|
111
122
|
].join(", ");
|
|
112
|
-
|
|
123
|
+
if (needsContradictionCheck) {
|
|
124
|
+
transactionGoal = `rdf_transaction((${goals}, check_req_contradiction('${escapeAtom(id)}')))`;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
transactionGoal = `rdf_transaction((${goals}))`;
|
|
128
|
+
}
|
|
113
129
|
}
|
|
114
130
|
const txResult = await prolog.query(transactionGoal);
|
|
115
131
|
if (!txResult.success) {
|
|
116
|
-
|
|
132
|
+
// Format error message without exposing raw transaction goal
|
|
133
|
+
const formattedError = formatUpsertError(id, txResult.error);
|
|
134
|
+
throw new Error(formattedError);
|
|
135
|
+
}
|
|
136
|
+
await recordEntityAudit(prolog, type, entity);
|
|
137
|
+
for (const rel of relationships) {
|
|
138
|
+
await recordRelationshipAudit(prolog, rel);
|
|
117
139
|
}
|
|
118
140
|
// Update counters
|
|
119
141
|
if (isUpdate) {
|
|
@@ -131,10 +153,6 @@ export async function handleKbUpsert(prolog, args) {
|
|
|
131
153
|
if (!saveResult.success) {
|
|
132
154
|
throw new Error(`Failed to save KB after upsert: ${saveResult.error || "Unknown error"}`);
|
|
133
155
|
}
|
|
134
|
-
let contradictionPairsDetected;
|
|
135
|
-
if (type === "req" && !args._skipContradictionCheck) {
|
|
136
|
-
contradictionPairsDetected = await detectContradictionPairs(prolog, id);
|
|
137
|
-
}
|
|
138
156
|
if (type === "symbol") {
|
|
139
157
|
try {
|
|
140
158
|
await refreshCoordinatesForSymbolId(id);
|
|
@@ -150,16 +168,13 @@ export async function handleKbUpsert(prolog, args) {
|
|
|
150
168
|
content: [
|
|
151
169
|
{
|
|
152
170
|
type: "text",
|
|
153
|
-
text:
|
|
154
|
-
? `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s). Contradiction probe detected ${contradictionPairsDetected} potential conflict pair(s).`
|
|
155
|
-
: `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
|
|
171
|
+
text: `Upserted ${id} (${created > 0 ? "created" : "updated"}) with ${relationshipsCreated} relationship(s).`,
|
|
156
172
|
},
|
|
157
173
|
],
|
|
158
174
|
structuredContent: {
|
|
159
175
|
created,
|
|
160
176
|
updated,
|
|
161
177
|
relationships_created: relationshipsCreated,
|
|
162
|
-
contradiction_pairs_detected: contradictionPairsDetected,
|
|
163
178
|
},
|
|
164
179
|
};
|
|
165
180
|
}
|
|
@@ -168,27 +183,28 @@ export async function handleKbUpsert(prolog, args) {
|
|
|
168
183
|
throw new Error(`Upsert execution failed: ${message}`);
|
|
169
184
|
}
|
|
170
185
|
}
|
|
171
|
-
async function detectContradictionPairs(prolog, reqId) {
|
|
172
|
-
const escaped = escapeAtom(reqId);
|
|
173
|
-
const goal = `aggregate_all(count, (contradicting_reqs(A, B, _), (A = '${escaped}' ; B = '${escaped}' ; A = 'file:///${escaped}' ; B = 'file:///${escaped}')), Count)`;
|
|
174
|
-
const result = await prolog.query(goal);
|
|
175
|
-
if (!result.success) {
|
|
176
|
-
return 0;
|
|
177
|
-
}
|
|
178
|
-
const raw = result.bindings.Count;
|
|
179
|
-
const count = Number(raw);
|
|
180
|
-
return Number.isFinite(count) ? count : 0;
|
|
181
|
-
}
|
|
182
186
|
/**
|
|
183
187
|
* Build Prolog property list from entity object
|
|
184
188
|
* Returns simple Key=Value format without typed literals
|
|
185
189
|
* Example output: "[id='test-1', title=\"Test\", status=active]"
|
|
190
|
+
* implements REQ-002
|
|
186
191
|
*/
|
|
187
192
|
function buildPropertyList(entity) {
|
|
188
193
|
const pairs = [];
|
|
189
194
|
// Defined internally to ensure thread safety and avoid initialization order issues.
|
|
190
195
|
// Using simple arrays instead of Sets is performant enough for small lists and avoids Set allocation overhead.
|
|
191
|
-
|
|
196
|
+
// implements REQ-002
|
|
197
|
+
const ATOM_FIELDS = [
|
|
198
|
+
"status",
|
|
199
|
+
"owner",
|
|
200
|
+
"priority",
|
|
201
|
+
"severity",
|
|
202
|
+
// Typed fact enum fields must be atoms for Prolog validation
|
|
203
|
+
"fact_kind",
|
|
204
|
+
"operator",
|
|
205
|
+
"value_type",
|
|
206
|
+
"polarity",
|
|
207
|
+
];
|
|
192
208
|
const STRING_FIELDS = [
|
|
193
209
|
"id",
|
|
194
210
|
"title",
|
|
@@ -211,10 +227,10 @@ function buildPropertyList(entity) {
|
|
|
211
227
|
prologValue = toPrologAtom(value);
|
|
212
228
|
}
|
|
213
229
|
else if (STRING_FIELDS.includes(key) && typeof value === "string") {
|
|
214
|
-
prologValue =
|
|
230
|
+
prologValue = `${toPrologString(value)}`;
|
|
215
231
|
}
|
|
216
232
|
else if (typeof value === "string") {
|
|
217
|
-
prologValue =
|
|
233
|
+
prologValue = `${toPrologString(value)}`;
|
|
218
234
|
}
|
|
219
235
|
else if (typeof value === "number") {
|
|
220
236
|
prologValue = String(value);
|
|
@@ -223,7 +239,7 @@ function buildPropertyList(entity) {
|
|
|
223
239
|
prologValue = value ? "true" : "false";
|
|
224
240
|
}
|
|
225
241
|
else {
|
|
226
|
-
prologValue =
|
|
242
|
+
prologValue = `${toPrologString(String(value))}`;
|
|
227
243
|
}
|
|
228
244
|
pairs.push(`${key}=${prologValue}`);
|
|
229
245
|
}
|
|
@@ -240,21 +256,122 @@ function buildRelationshipMetadata(rel) {
|
|
|
240
256
|
continue;
|
|
241
257
|
let prologValue;
|
|
242
258
|
if (typeof value === "string") {
|
|
243
|
-
prologValue =
|
|
259
|
+
prologValue = `${toPrologString(value)}`;
|
|
244
260
|
}
|
|
245
261
|
else if (typeof value === "number") {
|
|
246
262
|
prologValue = String(value);
|
|
247
263
|
}
|
|
248
264
|
else {
|
|
249
|
-
prologValue =
|
|
265
|
+
prologValue = `${toPrologString(String(value))}`;
|
|
250
266
|
}
|
|
251
267
|
pairs.push(`${key}=${prologValue}`);
|
|
252
268
|
}
|
|
253
269
|
return `[${pairs.join(", ")}]`;
|
|
254
270
|
}
|
|
255
271
|
/**
|
|
256
|
-
*
|
|
272
|
+
* Ensure all relationship rows belong to the entity being upserted.
|
|
273
|
+
* Rejects foreign-source relationship writes in the same request.
|
|
274
|
+
* implements REQ-002, REQ-011
|
|
275
|
+
*/
|
|
276
|
+
function validateRelationshipSources(entityId, relationships) {
|
|
277
|
+
for (const rel of relationships) {
|
|
278
|
+
if (rel.from !== entityId) {
|
|
279
|
+
throw new Error(`Relationship source must match the upserted entity ${entityId}; received from=${String(rel.from)}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Validate strict-lane fact_kind pairing for constrains/requires_property relationships.
|
|
285
|
+
* constrains targets must be subject, observation, or meta facts (or legacy without fact_kind).
|
|
286
|
+
* requires_property targets must be property_value, observation, or meta facts (or legacy without fact_kind).
|
|
287
|
+
* implements REQ-011
|
|
288
|
+
*/
|
|
289
|
+
async function validateStrictLanePairing(prolog, relationships) {
|
|
290
|
+
// implements REQ-011
|
|
291
|
+
for (const rel of relationships) {
|
|
292
|
+
if (rel.type === "constrains") {
|
|
293
|
+
const targetId = rel.to;
|
|
294
|
+
// Reject if target is a fact with fact_kind=property_value.
|
|
295
|
+
// Allow: legacy (no fact_kind), subject, observation, meta, or non-existent
|
|
296
|
+
// (non-existent targets are caught by relationship type validation elsewhere).
|
|
297
|
+
const rejectResult = await prolog.query(`once((kb_entity('${escapeAtom(targetId)}', fact, _SlpProps), memberchk(fact_kind=_SlpFK, _SlpProps), normalize_term_atom(_SlpFK, property_value)))`);
|
|
298
|
+
if (rejectResult.success) {
|
|
299
|
+
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.`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else if (rel.type === "requires_property") {
|
|
303
|
+
const targetId = rel.to;
|
|
304
|
+
// Reject if target is a fact with fact_kind=subject.
|
|
305
|
+
// Allow: legacy (no fact_kind), property_value, observation, meta, or non-existent.
|
|
306
|
+
const rejectResult = await prolog.query(`once((kb_entity('${escapeAtom(targetId)}', fact, _SlpProps), memberchk(fact_kind=_SlpFK, _SlpProps), normalize_term_atom(_SlpFK, subject)))`);
|
|
307
|
+
if (rejectResult.success) {
|
|
308
|
+
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.`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Record audit entry for a successfully committed entity mutation.
|
|
315
|
+
* Called only after the RDF transaction succeeds.
|
|
316
|
+
* implements REQ-011
|
|
317
|
+
*/
|
|
318
|
+
async function recordEntityAudit(prolog, type, entity) {
|
|
319
|
+
const props = buildPropertyList(entity);
|
|
320
|
+
const result = await prolog.query(`kb_log_entity_upsert(${type}, ${props})`);
|
|
321
|
+
if (!result.success) {
|
|
322
|
+
throw new Error(`Failed to record audit entry for ${String(entity.id)}: ${result.error || "Unknown error"}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Record audit entry for a successfully committed relationship mutation.
|
|
327
|
+
* Called only after the RDF transaction succeeds.
|
|
328
|
+
* implements REQ-011
|
|
257
329
|
*/
|
|
258
|
-
function
|
|
259
|
-
|
|
330
|
+
async function recordRelationshipAudit(prolog, rel) {
|
|
331
|
+
const relType = rel.type;
|
|
332
|
+
const from = rel.from;
|
|
333
|
+
const to = rel.to;
|
|
334
|
+
const metadata = buildRelationshipMetadata(rel);
|
|
335
|
+
const result = await prolog.query(`kb_log_relationship_upsert(${relType}, '${escapeAtom(from)}', '${escapeAtom(to)}', ${metadata})`);
|
|
336
|
+
if (!result.success) {
|
|
337
|
+
throw new Error(`Failed to record relationship audit entry ${from}->${to}: ${result.error || "Unknown error"}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Format upsert error message for user-facing display.
|
|
342
|
+
* Removes raw transaction goals and extracts meaningful contradiction details.
|
|
343
|
+
* implements REQ-011
|
|
344
|
+
*/
|
|
345
|
+
function formatUpsertError(entityId, rawError) {
|
|
346
|
+
if (!rawError) {
|
|
347
|
+
return `Failed to upsert entity ${entityId}: Unknown error`;
|
|
348
|
+
}
|
|
349
|
+
// Check for contradiction error - Prolog returns kb_contradiction([...]) term
|
|
350
|
+
// Try to extract readable details from the term
|
|
351
|
+
const contradictionMatch = rawError.match(/kb_contradiction\(\s*\[([^\]]+)\]\s*\)/);
|
|
352
|
+
if (contradictionMatch) {
|
|
353
|
+
// Extract individual conflict details from the list
|
|
354
|
+
const details = contradictionMatch[1];
|
|
355
|
+
// Parse out readable parts - each entry is like 'Reason'-'ReqId'
|
|
356
|
+
const conflicts = [];
|
|
357
|
+
const conflictRegex = /'([^']+)'-'([^']+)'/g;
|
|
358
|
+
let execResult = conflictRegex.exec(details);
|
|
359
|
+
while (execResult !== null) {
|
|
360
|
+
const reason = execResult[1];
|
|
361
|
+
const otherReq = execResult[2];
|
|
362
|
+
conflicts.push(` - Conflicts with ${otherReq}: ${reason}`);
|
|
363
|
+
execResult = conflictRegex.exec(details);
|
|
364
|
+
}
|
|
365
|
+
if (conflicts.length > 0) {
|
|
366
|
+
const uniqueConflicts = [...new Set(conflicts)];
|
|
367
|
+
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.`;
|
|
368
|
+
}
|
|
369
|
+
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.`;
|
|
370
|
+
}
|
|
371
|
+
// Check for RDF transaction error
|
|
372
|
+
if (rawError.includes("rdf_transaction")) {
|
|
373
|
+
return `Failed to upsert entity ${entityId}: Transaction failed`;
|
|
374
|
+
}
|
|
375
|
+
// Default: return cleaned error without raw goal
|
|
376
|
+
return `Failed to upsert entity ${entityId}: ${rawError}`;
|
|
260
377
|
}
|