kibi-mcp 0.1.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/bin/kibi-mcp +59 -0
- package/dist/env.js +99 -0
- package/dist/mcpcat.js +129 -0
- package/dist/server.js +673 -0
- package/dist/tools/branch.js +208 -0
- package/dist/tools/check.js +349 -0
- package/dist/tools/context.js +280 -0
- package/dist/tools/coverage-report.js +91 -0
- package/dist/tools/delete.js +100 -0
- package/dist/tools/derive.js +311 -0
- package/dist/tools/impact.js +70 -0
- package/dist/tools/list-types.js +75 -0
- package/dist/tools/prolog-list.js +176 -0
- package/dist/tools/query-relationships.js +176 -0
- package/dist/tools/query.js +364 -0
- package/dist/tools/suggest-shared-facts.js +138 -0
- package/dist/tools/symbols.js +219 -0
- package/dist/tools/upsert.js +228 -0
- package/dist/tools-config.js +448 -0
- package/dist/workspace.js +126 -0
- package/package.json +43 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Kibi — repo-local, per-branch, queryable long-term memory for software projects
|
|
3
|
+
Copyright (C) 2026 Piotr Franczyk
|
|
4
|
+
|
|
5
|
+
This program is free software: you can redistribute it and/or modify
|
|
6
|
+
it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
(at your option) any later version.
|
|
9
|
+
|
|
10
|
+
This program is distributed in the hope that it will be useful,
|
|
11
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
GNU Affero General Public License for more details.
|
|
14
|
+
|
|
15
|
+
You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
*/
|
|
18
|
+
/*
|
|
19
|
+
How to apply this header to source files (examples)
|
|
20
|
+
|
|
21
|
+
1) Prepend header to a single file (POSIX shells):
|
|
22
|
+
|
|
23
|
+
cat LICENSE_HEADER.txt "$FILE" > "$FILE".with-header && mv "$FILE".with-header "$FILE"
|
|
24
|
+
|
|
25
|
+
2) Apply to multiple files (example: the project's main entry files):
|
|
26
|
+
|
|
27
|
+
for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp packages/cli/src/*.ts packages/mcp/src/*.ts; do
|
|
28
|
+
if [ -f "$f" ]; then
|
|
29
|
+
cp "$f" "$f".bak
|
|
30
|
+
(cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
|
|
31
|
+
fi
|
|
32
|
+
done
|
|
33
|
+
|
|
34
|
+
3) Avoid duplicating the header: run a quick guard to only add if missing
|
|
35
|
+
|
|
36
|
+
for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp; do
|
|
37
|
+
if [ -f "$f" ]; then
|
|
38
|
+
if ! head -n 5 "$f" | grep -q "Copyright (C) 2026 Piotr Franczyk"; then
|
|
39
|
+
cp "$f" "$f".bak
|
|
40
|
+
(cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
|
|
41
|
+
fi
|
|
42
|
+
fi
|
|
43
|
+
done
|
|
44
|
+
*/
|
|
45
|
+
import { execSync } from "node:child_process";
|
|
46
|
+
import * as fs from "node:fs";
|
|
47
|
+
import * as path from "node:path";
|
|
48
|
+
import { resolveKbPath, resolveWorkspaceRoot } from "../workspace.js";
|
|
49
|
+
/**
|
|
50
|
+
* Handle kb_branch_ensure tool calls - create branch KB if not exists
|
|
51
|
+
*/
|
|
52
|
+
export async function handleKbBranchEnsure(_prolog, args) {
|
|
53
|
+
const { branch } = args;
|
|
54
|
+
if (!branch || branch.trim() === "") {
|
|
55
|
+
throw new Error("Branch name is required");
|
|
56
|
+
}
|
|
57
|
+
// Sanitize branch name (prevent path traversal)
|
|
58
|
+
const isSafe = (name) => {
|
|
59
|
+
// No empty or excessively long names
|
|
60
|
+
if (!name || name.length > 255)
|
|
61
|
+
return false;
|
|
62
|
+
// No path traversal or absolute paths
|
|
63
|
+
if (name.includes("..") || path.isAbsolute(name) || name.startsWith("/")) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
// Whitelist characters (alphanumeric, dot, underscore, hyphen, forward slash)
|
|
67
|
+
if (!/^[a-zA-Z0-9._\-/]+$/.test(name))
|
|
68
|
+
return false;
|
|
69
|
+
// No redundant slashes or trailing slash/dot
|
|
70
|
+
if (name.includes("//") ||
|
|
71
|
+
name.endsWith("/") ||
|
|
72
|
+
name.endsWith(".") ||
|
|
73
|
+
name.includes("\\")) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
};
|
|
78
|
+
if (!isSafe(branch)) {
|
|
79
|
+
throw new Error(`Invalid branch name: ${branch}`);
|
|
80
|
+
}
|
|
81
|
+
const safeBranch = branch;
|
|
82
|
+
try {
|
|
83
|
+
const workspaceRoot = resolveWorkspaceRoot();
|
|
84
|
+
const branchPath = resolveKbPath(workspaceRoot, safeBranch);
|
|
85
|
+
const developPath = resolveKbPath(workspaceRoot, "develop");
|
|
86
|
+
// Check if branch KB already exists
|
|
87
|
+
if (fs.existsSync(branchPath)) {
|
|
88
|
+
return {
|
|
89
|
+
content: [
|
|
90
|
+
{
|
|
91
|
+
type: "text",
|
|
92
|
+
text: `Branch KB '${safeBranch}' already exists`,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
structuredContent: {
|
|
96
|
+
created: false,
|
|
97
|
+
path: branchPath,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// Ensure develop branch exists
|
|
102
|
+
if (!fs.existsSync(developPath)) {
|
|
103
|
+
throw new Error("Develop branch KB does not exist. Run 'kb init' first.");
|
|
104
|
+
}
|
|
105
|
+
// Copy develop branch KB to new branch
|
|
106
|
+
fs.cpSync(developPath, branchPath, { recursive: true });
|
|
107
|
+
return {
|
|
108
|
+
content: [
|
|
109
|
+
{
|
|
110
|
+
type: "text",
|
|
111
|
+
text: `Created branch KB '${safeBranch}' from develop`,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
structuredContent: {
|
|
115
|
+
created: true,
|
|
116
|
+
path: branchPath,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
throw new Error(`Branch ensure failed: ${message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Handle kb_branch_gc tool calls - garbage collect stale branch KBs
|
|
127
|
+
*/
|
|
128
|
+
export async function handleKbBranchGc(_prolog, args) {
|
|
129
|
+
const { dry_run = true } = args;
|
|
130
|
+
try {
|
|
131
|
+
const workspaceRoot = resolveWorkspaceRoot();
|
|
132
|
+
const kbRoot = path.dirname(resolveKbPath(workspaceRoot, "develop"));
|
|
133
|
+
// Check if .kb/branches exists
|
|
134
|
+
if (!fs.existsSync(kbRoot)) {
|
|
135
|
+
return {
|
|
136
|
+
content: [
|
|
137
|
+
{
|
|
138
|
+
type: "text",
|
|
139
|
+
text: "No branch KBs found (.kb/branches does not exist)",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
structuredContent: {
|
|
143
|
+
stale: [],
|
|
144
|
+
deleted: 0,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
let gitBranches;
|
|
149
|
+
try {
|
|
150
|
+
execSync("git rev-parse --git-dir", {
|
|
151
|
+
encoding: "utf-8",
|
|
152
|
+
cwd: workspaceRoot,
|
|
153
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
154
|
+
env: process.env,
|
|
155
|
+
});
|
|
156
|
+
const output = execSync("git branch --format='%(refname:short)'", {
|
|
157
|
+
encoding: "utf-8",
|
|
158
|
+
cwd: workspaceRoot,
|
|
159
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
160
|
+
env: process.env,
|
|
161
|
+
});
|
|
162
|
+
gitBranches = new Set(output
|
|
163
|
+
.trim()
|
|
164
|
+
.split("\n")
|
|
165
|
+
.map((b) => b.trim().replace(/^'|'$/g, ""))
|
|
166
|
+
.filter((b) => b));
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
170
|
+
throw new Error(`Not in a git repository or git command failed: ${message}`);
|
|
171
|
+
}
|
|
172
|
+
// Get all KB branches
|
|
173
|
+
const kbBranches = fs
|
|
174
|
+
.readdirSync(kbRoot, { withFileTypes: true })
|
|
175
|
+
.filter((dirent) => dirent.isDirectory())
|
|
176
|
+
.map((dirent) => dirent.name);
|
|
177
|
+
// Find stale branches (KB exists but git branch doesn't, excluding develop)
|
|
178
|
+
const staleBranches = kbBranches.filter((kb) => kb !== "develop" && !gitBranches.has(kb));
|
|
179
|
+
// Delete stale branches if not dry run
|
|
180
|
+
let deletedCount = 0;
|
|
181
|
+
if (!dry_run && staleBranches.length > 0) {
|
|
182
|
+
for (const branch of staleBranches) {
|
|
183
|
+
const branchPath = path.join(kbRoot, branch);
|
|
184
|
+
fs.rmSync(branchPath, { recursive: true, force: true });
|
|
185
|
+
deletedCount++;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const summary = dry_run
|
|
189
|
+
? `Found ${staleBranches.length} stale branch KB(s) (dry run - not deleted)`
|
|
190
|
+
: `Deleted ${deletedCount} stale branch KB(s)`;
|
|
191
|
+
return {
|
|
192
|
+
content: [
|
|
193
|
+
{
|
|
194
|
+
type: "text",
|
|
195
|
+
text: summary,
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
structuredContent: {
|
|
199
|
+
stale: staleBranches,
|
|
200
|
+
deleted: deletedCount,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
206
|
+
throw new Error(`Branch GC failed: ${message}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Kibi — repo-local, per-branch, queryable long-term memory for software projects
|
|
3
|
+
Copyright (C) 2026 Piotr Franczyk
|
|
4
|
+
|
|
5
|
+
This program is free software: you can redistribute it and/or modify
|
|
6
|
+
it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
(at your option) any later version.
|
|
9
|
+
|
|
10
|
+
This program is distributed in the hope that it will be useful,
|
|
11
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
GNU Affero General Public License for more details.
|
|
14
|
+
|
|
15
|
+
You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
*/
|
|
18
|
+
/*
|
|
19
|
+
How to apply this header to source files (examples)
|
|
20
|
+
|
|
21
|
+
1) Prepend header to a single file (POSIX shells):
|
|
22
|
+
|
|
23
|
+
cat LICENSE_HEADER.txt "$FILE" > "$FILE".with-header && mv "$FILE".with-header "$FILE"
|
|
24
|
+
|
|
25
|
+
2) Apply to multiple files (example: the project's main entry files):
|
|
26
|
+
|
|
27
|
+
for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp packages/cli/src/*.ts packages/mcp/src/*.ts; do
|
|
28
|
+
if [ -f "$f" ]; then
|
|
29
|
+
cp "$f" "$f".bak
|
|
30
|
+
(cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
|
|
31
|
+
fi
|
|
32
|
+
done
|
|
33
|
+
|
|
34
|
+
3) Avoid duplicating the header: run a quick guard to only add if missing
|
|
35
|
+
|
|
36
|
+
for f in packages/cli/bin/kibi packages/mcp/bin/kibi-mcp; do
|
|
37
|
+
if [ -f "$f" ]; then
|
|
38
|
+
if ! head -n 5 "$f" | grep -q "Copyright (C) 2026 Piotr Franczyk"; then
|
|
39
|
+
cp "$f" "$f".bak
|
|
40
|
+
(cat LICENSE_HEADER.txt; echo; cat "$f" ) > "$f".new && mv "$f".new "$f"
|
|
41
|
+
fi
|
|
42
|
+
fi
|
|
43
|
+
done
|
|
44
|
+
*/
|
|
45
|
+
import * as path from "node:path";
|
|
46
|
+
import { parsePairList } from "./prolog-list.js";
|
|
47
|
+
/**
|
|
48
|
+
* Handle kb_check tool calls - run validation rules on the KB
|
|
49
|
+
* Reuses validation logic from CLI check command
|
|
50
|
+
*/
|
|
51
|
+
export async function handleKbCheck(prolog, args) {
|
|
52
|
+
const { rules } = args;
|
|
53
|
+
try {
|
|
54
|
+
const violations = [];
|
|
55
|
+
const allEntityIds = await getAllEntityIds(prolog);
|
|
56
|
+
// Run all validation rules (or specific rules if provided)
|
|
57
|
+
const allRules = [
|
|
58
|
+
"must-priority-coverage",
|
|
59
|
+
"no-dangling-refs",
|
|
60
|
+
"no-cycles",
|
|
61
|
+
"required-fields",
|
|
62
|
+
"symbol-coverage",
|
|
63
|
+
];
|
|
64
|
+
const rulesToRun = rules && rules.length > 0 ? rules : allRules;
|
|
65
|
+
if (rulesToRun.includes("must-priority-coverage")) {
|
|
66
|
+
violations.push(...(await checkMustPriorityCoverage(prolog)));
|
|
67
|
+
}
|
|
68
|
+
if (rulesToRun.includes("no-dangling-refs")) {
|
|
69
|
+
violations.push(...(await checkNoDanglingRefs(prolog)));
|
|
70
|
+
}
|
|
71
|
+
if (rulesToRun.includes("no-cycles")) {
|
|
72
|
+
violations.push(...(await checkNoCycles(prolog)));
|
|
73
|
+
}
|
|
74
|
+
if (rulesToRun.includes("required-fields")) {
|
|
75
|
+
violations.push(...(await checkRequiredFields(prolog, allEntityIds)));
|
|
76
|
+
}
|
|
77
|
+
if (rulesToRun.includes("symbol-coverage")) {
|
|
78
|
+
violations.push(...(await checkSymbolCoverage(prolog)));
|
|
79
|
+
}
|
|
80
|
+
// Return MCP structured response
|
|
81
|
+
const summary = violations.length === 0
|
|
82
|
+
? "No violations found"
|
|
83
|
+
: `${violations.length} violations found`;
|
|
84
|
+
return {
|
|
85
|
+
content: [
|
|
86
|
+
{
|
|
87
|
+
type: "text",
|
|
88
|
+
text: summary,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
structuredContent: {
|
|
92
|
+
violations,
|
|
93
|
+
count: violations.length,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
99
|
+
throw new Error(`Check execution failed: ${message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function checkMustPriorityCoverage(prolog) {
|
|
103
|
+
const violations = [];
|
|
104
|
+
const gapsResult = await prolog.query("setof([Req,Reason], coverage_gap(Req, Reason), Rows)");
|
|
105
|
+
if (!gapsResult.success || !gapsResult.bindings.Rows) {
|
|
106
|
+
return violations;
|
|
107
|
+
}
|
|
108
|
+
const gaps = parsePairList(gapsResult.bindings.Rows);
|
|
109
|
+
for (const [reqId, reason] of gaps) {
|
|
110
|
+
const entityResult = await prolog.query(`kb_entity('${reqId}', req, Props)`);
|
|
111
|
+
let source = "";
|
|
112
|
+
if (entityResult.success && entityResult.bindings.Props) {
|
|
113
|
+
const propsStr = entityResult.bindings.Props;
|
|
114
|
+
const sourceMatch = propsStr.match(/source\s*=\s*\^\^?\("([^"]+)"/);
|
|
115
|
+
if (sourceMatch) {
|
|
116
|
+
source = sourceMatch[1];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const missing = [];
|
|
120
|
+
if (reason.includes("scenario")) {
|
|
121
|
+
missing.push("scenario");
|
|
122
|
+
}
|
|
123
|
+
if (reason.includes("test")) {
|
|
124
|
+
missing.push("test");
|
|
125
|
+
}
|
|
126
|
+
violations.push({
|
|
127
|
+
rule: "must-priority-coverage",
|
|
128
|
+
entityId: reqId,
|
|
129
|
+
description: `Must-priority requirement lacks ${missing.join(" and ")} coverage`,
|
|
130
|
+
source,
|
|
131
|
+
suggestion: missing
|
|
132
|
+
.map((m) => `Create ${m} that covers this requirement`)
|
|
133
|
+
.join("; "),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return violations;
|
|
137
|
+
}
|
|
138
|
+
async function getAllEntityIds(prolog, type) {
|
|
139
|
+
const typeFilter = type ? `, Type = ${type}` : "";
|
|
140
|
+
const query = `findall(Id, (kb_entity(Id, Type, _)${typeFilter}), Ids)`;
|
|
141
|
+
const result = await prolog.query(query);
|
|
142
|
+
if (!result.success || !result.bindings.Ids) {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
const idsStr = result.bindings.Ids;
|
|
146
|
+
const match = idsStr.match(/\[(.*)\]/);
|
|
147
|
+
if (!match) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const content = match[1].trim();
|
|
151
|
+
if (!content) {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
return content.split(",").map((id) => id.trim().replace(/^'|'$/g, ""));
|
|
155
|
+
}
|
|
156
|
+
async function checkNoDanglingRefs(prolog) {
|
|
157
|
+
const violations = [];
|
|
158
|
+
// Get all entity IDs once
|
|
159
|
+
const allEntityIds = new Set(await getAllEntityIds(prolog));
|
|
160
|
+
// Get all relationships by querying all known relationship types
|
|
161
|
+
const relTypes = [
|
|
162
|
+
"depends_on",
|
|
163
|
+
"verified_by",
|
|
164
|
+
"validates",
|
|
165
|
+
"specified_by",
|
|
166
|
+
"relates_to",
|
|
167
|
+
];
|
|
168
|
+
const allRels = [];
|
|
169
|
+
for (const relType of relTypes) {
|
|
170
|
+
const relsResult = await prolog.query(`findall([From,To], kb_relationship(${relType}, From, To), Rels)`);
|
|
171
|
+
if (relsResult.success && relsResult.bindings.Rels) {
|
|
172
|
+
const relsStr = relsResult.bindings.Rels;
|
|
173
|
+
const match = relsStr.match(/\[(.*)\]/);
|
|
174
|
+
if (match) {
|
|
175
|
+
const content = match[1].trim();
|
|
176
|
+
if (content) {
|
|
177
|
+
const relMatches = content.matchAll(/\[([^,]+),([^\]]+)\]/g);
|
|
178
|
+
for (const relMatch of relMatches) {
|
|
179
|
+
const fromId = relMatch[1].trim().replace(/^'|'$/g, "");
|
|
180
|
+
const toId = relMatch[2].trim().replace(/^'|'$/g, "");
|
|
181
|
+
allRels.push({ from: fromId, to: toId });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Check all collected relationships for dangling refs
|
|
188
|
+
for (const rel of allRels) {
|
|
189
|
+
if (!allEntityIds.has(rel.from)) {
|
|
190
|
+
violations.push({
|
|
191
|
+
rule: "no-dangling-refs",
|
|
192
|
+
entityId: rel.from,
|
|
193
|
+
description: `Relationship references non-existent entity: ${rel.from}`,
|
|
194
|
+
suggestion: "Remove relationship or create missing entity",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (!allEntityIds.has(rel.to)) {
|
|
198
|
+
violations.push({
|
|
199
|
+
rule: "no-dangling-refs",
|
|
200
|
+
entityId: rel.to,
|
|
201
|
+
description: `Relationship references non-existent entity: ${rel.to}`,
|
|
202
|
+
suggestion: "Remove relationship or create missing entity",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return violations;
|
|
207
|
+
}
|
|
208
|
+
async function checkNoCycles(prolog) {
|
|
209
|
+
const violations = [];
|
|
210
|
+
// Get all depends_on relationships
|
|
211
|
+
const depsResult = await prolog.query("findall([From,To], kb_relationship(depends_on, From, To), Deps)");
|
|
212
|
+
if (!depsResult.success || !depsResult.bindings.Deps) {
|
|
213
|
+
return violations;
|
|
214
|
+
}
|
|
215
|
+
const depsStr = depsResult.bindings.Deps;
|
|
216
|
+
const match = depsStr.match(/\[(.*)\]/);
|
|
217
|
+
if (!match) {
|
|
218
|
+
return violations;
|
|
219
|
+
}
|
|
220
|
+
const content = match[1].trim();
|
|
221
|
+
if (!content) {
|
|
222
|
+
return violations;
|
|
223
|
+
}
|
|
224
|
+
// Build adjacency map
|
|
225
|
+
const graph = new Map();
|
|
226
|
+
const depMatches = content.matchAll(/\[([^,]+),([^\]]+)\]/g);
|
|
227
|
+
for (const depMatch of depMatches) {
|
|
228
|
+
const from = depMatch[1].trim().replace(/^'|'$/g, "");
|
|
229
|
+
const to = depMatch[2].trim().replace(/^'|'$/g, "");
|
|
230
|
+
if (!graph.has(from)) {
|
|
231
|
+
graph.set(from, []);
|
|
232
|
+
}
|
|
233
|
+
const fromList = graph.get(from);
|
|
234
|
+
if (fromList) {
|
|
235
|
+
fromList.push(to);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// DFS to detect cycles
|
|
239
|
+
const visited = new Set();
|
|
240
|
+
const recStack = new Set();
|
|
241
|
+
function hasCycleDFS(node, path) {
|
|
242
|
+
visited.add(node);
|
|
243
|
+
recStack.add(node);
|
|
244
|
+
path.push(node);
|
|
245
|
+
const neighbors = graph.get(node) || [];
|
|
246
|
+
for (const neighbor of neighbors) {
|
|
247
|
+
if (!visited.has(neighbor)) {
|
|
248
|
+
const cyclePath = hasCycleDFS(neighbor, [...path]);
|
|
249
|
+
if (cyclePath)
|
|
250
|
+
return cyclePath;
|
|
251
|
+
}
|
|
252
|
+
else if (recStack.has(neighbor)) {
|
|
253
|
+
// Cycle detected
|
|
254
|
+
return [...path, neighbor];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
recStack.delete(node);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
// Check each node for cycles
|
|
261
|
+
for (const node of graph.keys()) {
|
|
262
|
+
if (!visited.has(node)) {
|
|
263
|
+
const cyclePath = hasCycleDFS(node, []);
|
|
264
|
+
if (cyclePath) {
|
|
265
|
+
const cycleWithSources = [];
|
|
266
|
+
for (const entityId of cyclePath) {
|
|
267
|
+
const entityResult = await prolog.query(`kb_entity('${entityId}', _, Props)`);
|
|
268
|
+
let sourceName = entityId;
|
|
269
|
+
if (entityResult.success && entityResult.bindings.Props) {
|
|
270
|
+
const propsStr = entityResult.bindings.Props;
|
|
271
|
+
const sourceMatch = propsStr.match(/source\s*=\s*\^\^?\("([^"]+)"/);
|
|
272
|
+
if (sourceMatch) {
|
|
273
|
+
sourceName = path.basename(sourceMatch[1], ".md");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
cycleWithSources.push(sourceName);
|
|
277
|
+
}
|
|
278
|
+
violations.push({
|
|
279
|
+
rule: "no-cycles",
|
|
280
|
+
entityId: cyclePath[0],
|
|
281
|
+
description: `Circular dependency detected: ${cycleWithSources.join(" → ")}`,
|
|
282
|
+
suggestion: "Break the cycle by removing one of the depends_on relationships",
|
|
283
|
+
});
|
|
284
|
+
break; // Report only first cycle found
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return violations;
|
|
289
|
+
}
|
|
290
|
+
async function checkRequiredFields(prolog, allEntityIds) {
|
|
291
|
+
const violations = [];
|
|
292
|
+
const required = [
|
|
293
|
+
"id",
|
|
294
|
+
"title",
|
|
295
|
+
"status",
|
|
296
|
+
"created_at",
|
|
297
|
+
"updated_at",
|
|
298
|
+
"source",
|
|
299
|
+
];
|
|
300
|
+
for (const entityId of allEntityIds) {
|
|
301
|
+
const result = await prolog.query(`kb_entity('${entityId}', Type, Props)`);
|
|
302
|
+
if (result.success && result.bindings.Props) {
|
|
303
|
+
// Parse properties list: [key1=value1, key2=value2, ...]
|
|
304
|
+
const propsStr = result.bindings.Props;
|
|
305
|
+
const propKeys = new Set();
|
|
306
|
+
// Extract keys from Props
|
|
307
|
+
const keyMatches = propsStr.matchAll(/(\w+)\s*=/g);
|
|
308
|
+
for (const match of keyMatches) {
|
|
309
|
+
propKeys.add(match[1]);
|
|
310
|
+
}
|
|
311
|
+
// Check for missing required fields
|
|
312
|
+
for (const field of required) {
|
|
313
|
+
if (!propKeys.has(field)) {
|
|
314
|
+
violations.push({
|
|
315
|
+
rule: "required-fields",
|
|
316
|
+
entityId: entityId,
|
|
317
|
+
description: `Missing required field: ${field}`,
|
|
318
|
+
suggestion: `Add ${field} to entity definition`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return violations;
|
|
325
|
+
}
|
|
326
|
+
async function checkSymbolCoverage(prolog) {
|
|
327
|
+
const violations = [];
|
|
328
|
+
const uncoveredResult = await prolog.query("setof(Symbol, (kb_entity(Symbol, symbol, _), \\+ transitively_implements(Symbol, _)), Symbols)");
|
|
329
|
+
if (uncoveredResult.success && uncoveredResult.bindings.Symbols) {
|
|
330
|
+
const symbolsStr = uncoveredResult.bindings.Symbols;
|
|
331
|
+
const match = symbolsStr.match(/\[(.*)\]/);
|
|
332
|
+
if (match) {
|
|
333
|
+
const content = match[1].trim();
|
|
334
|
+
if (content) {
|
|
335
|
+
const symbolMatches = content.matchAll(/'([^']+)'/g);
|
|
336
|
+
for (const symbolMatch of symbolMatches) {
|
|
337
|
+
const symbolId = symbolMatch[1];
|
|
338
|
+
violations.push({
|
|
339
|
+
rule: "symbol-coverage",
|
|
340
|
+
entityId: symbolId,
|
|
341
|
+
description: "Code symbol is not traceable to any functional requirement.",
|
|
342
|
+
suggestion: "Update symbols.yaml to link this symbol to a related requirement.",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return violations;
|
|
349
|
+
}
|