kibi-mcp 0.17.4 → 0.19.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-helpers.js +159 -0
- package/dist/diagnostics.js +117 -100
- package/dist/semantic-advisor/analyze-prose.js +25 -0
- package/dist/server/diagnostic-usage.js +74 -0
- package/dist/server/docs.js +7 -6
- package/dist/server/json-schema-to-zod.js +104 -0
- package/dist/server/tool-registration.js +131 -0
- package/dist/server/tool-types.js +1 -0
- package/dist/server/tools-runtime.js +76 -0
- package/dist/server/tools.js +24 -287
- package/dist/tools/check-config.js +67 -0
- package/dist/tools/check-format.js +92 -0
- package/dist/tools/check-impact.js +45 -0
- package/dist/tools/check-prolog.js +51 -0
- package/dist/tools/check-types.js +1 -0
- package/dist/tools/check.js +64 -148
- package/dist/tools/query-plan-safety.js +58 -0
- package/dist/tools/relationship-validation.js +118 -0
- package/dist/tools/skills.js +20 -2
- package/dist/tools/upsert.js +23 -1
- package/dist/tools/validate-upsert.js +13 -1
- package/dist/tools-config.js +40 -2
- package/package.json +3 -3
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { DEFAULT_CHECKS_CONFIG, RULE_NAMES, } from "kibi-cli/public/check-types";
|
|
21
|
+
// implements REQ-002
|
|
22
|
+
export async function loadChecksConfig(workspaceRoot) {
|
|
23
|
+
const configPath = path.join(workspaceRoot, ".kb", "config.json");
|
|
24
|
+
try {
|
|
25
|
+
const content = await readFile(configPath, "utf8");
|
|
26
|
+
const parsed = JSON.parse(content);
|
|
27
|
+
const parsedRules = parsed.checks?.rules;
|
|
28
|
+
const normalizedRules = {
|
|
29
|
+
...DEFAULT_CHECKS_CONFIG.rules,
|
|
30
|
+
};
|
|
31
|
+
if (parsedRules && typeof parsedRules === "object") {
|
|
32
|
+
for (const [key, value] of Object.entries(parsedRules)) {
|
|
33
|
+
if (typeof value === "boolean") {
|
|
34
|
+
normalizedRules[key] = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const parsedSt = parsed.checks?.symbolTraceability;
|
|
39
|
+
const normalizedSt = { ...DEFAULT_CHECKS_CONFIG.symbolTraceability };
|
|
40
|
+
if (parsedSt &&
|
|
41
|
+
typeof parsedSt === "object" &&
|
|
42
|
+
typeof parsedSt.requireAdr === "boolean") {
|
|
43
|
+
normalizedSt.requireAdr = parsedSt.requireAdr;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
rules: normalizedRules,
|
|
47
|
+
symbolTraceability: normalizedSt,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return DEFAULT_CHECKS_CONFIG;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// implements REQ-002
|
|
55
|
+
export function getEffectiveRules(configRules, requestedRules) {
|
|
56
|
+
if (requestedRules && requestedRules.length > 0) {
|
|
57
|
+
return new Set(requestedRules.filter((rule) => RULE_NAMES.has(rule)));
|
|
58
|
+
}
|
|
59
|
+
const effective = new Set();
|
|
60
|
+
for (const rule of RULE_NAMES) {
|
|
61
|
+
const enabled = configRules[rule] ?? true;
|
|
62
|
+
if (enabled) {
|
|
63
|
+
effective.add(rule);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return effective;
|
|
67
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
function formatDiagnosticsForMcp(diagnostics) {
|
|
2
|
+
return diagnostics.map((d) => ({
|
|
3
|
+
category: d.category,
|
|
4
|
+
severity: d.severity,
|
|
5
|
+
message: d.message,
|
|
6
|
+
...(d.file !== undefined ? { file: d.file } : {}),
|
|
7
|
+
...(d.suggestion !== undefined ? { suggestion: d.suggestion } : {}),
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
10
|
+
export function formatImpactText(impactResult) {
|
|
11
|
+
if (impactResult.impactDiagnostics.length === 0) {
|
|
12
|
+
return "No impact diagnostics found";
|
|
13
|
+
}
|
|
14
|
+
const details = impactResult.impactDiagnostics.map((diagnostic) => {
|
|
15
|
+
const files = diagnostic.files.length > 0
|
|
16
|
+
? diagnostic.files.join(", ")
|
|
17
|
+
: "unknown-source";
|
|
18
|
+
return `- ${diagnostic.id} | ${diagnostic.severity} | ${files} | ${diagnostic.message} Suggestion: ${diagnostic.suggestion}`;
|
|
19
|
+
});
|
|
20
|
+
return `${impactResult.impactDiagnostics.length} impact diagnostics found\n${details.join("\n")}`;
|
|
21
|
+
}
|
|
22
|
+
function formatOptionalList(label, values) {
|
|
23
|
+
if (values === undefined || values.length === 0) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
return [`${label}: ${values.join(", ")}`];
|
|
27
|
+
}
|
|
28
|
+
export function formatQualityDiagnosticsText(diagnostics) {
|
|
29
|
+
if (diagnostics.length === 0) {
|
|
30
|
+
return "No quality diagnostics found";
|
|
31
|
+
}
|
|
32
|
+
const details = diagnostics.map((diagnostic) => {
|
|
33
|
+
const parts = [
|
|
34
|
+
diagnostic.id,
|
|
35
|
+
diagnostic.severity,
|
|
36
|
+
diagnostic.category,
|
|
37
|
+
diagnostic.message,
|
|
38
|
+
];
|
|
39
|
+
const metadata = [
|
|
40
|
+
`Blocking: ${diagnostic.blocking ? "yes" : "no"}`,
|
|
41
|
+
...formatOptionalList("Files", diagnostic.files),
|
|
42
|
+
...formatOptionalList("Docs", diagnostic.docs),
|
|
43
|
+
...(diagnostic.entityId !== undefined
|
|
44
|
+
? [`Entity: ${diagnostic.entityId}`]
|
|
45
|
+
: []),
|
|
46
|
+
...(diagnostic.source !== undefined
|
|
47
|
+
? [`Source: ${diagnostic.source}`]
|
|
48
|
+
: []),
|
|
49
|
+
`Suggestion: ${diagnostic.suggestion}`,
|
|
50
|
+
];
|
|
51
|
+
return `- ${parts.join(" | ")}\n ${metadata.join("\n ")}`;
|
|
52
|
+
});
|
|
53
|
+
return `${diagnostics.length} quality diagnostic${diagnostics.length === 1 ? "" : "s"} found\n${details.join("\n")}`;
|
|
54
|
+
}
|
|
55
|
+
export function buildStructuredContent(input) {
|
|
56
|
+
const qualityDiagnostics = input.qualityDiagnostics ?? [];
|
|
57
|
+
return {
|
|
58
|
+
violations: [...input.violations],
|
|
59
|
+
count: input.violations.length,
|
|
60
|
+
diagnostics: formatDiagnosticsForMcp(input.diagnostics),
|
|
61
|
+
...(qualityDiagnostics.length > 0
|
|
62
|
+
? { qualityDiagnostics: [...qualityDiagnostics] }
|
|
63
|
+
: {}),
|
|
64
|
+
...(input.impactResult
|
|
65
|
+
? {
|
|
66
|
+
impactDiagnostics: input.impactResult.impactDiagnostics,
|
|
67
|
+
sourceFiles: input.impactResult.sourceFiles,
|
|
68
|
+
extractedSymbols: input.impactResult.extractedSymbols,
|
|
69
|
+
linkedEntities: input.impactResult.linkedEntities,
|
|
70
|
+
nextActions: input.impactResult.nextActions,
|
|
71
|
+
}
|
|
72
|
+
: {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function formatViolationText(violations) {
|
|
76
|
+
if (violations.length === 0) {
|
|
77
|
+
return "No violations found";
|
|
78
|
+
}
|
|
79
|
+
const details = violations.map((violation) => {
|
|
80
|
+
const parts = [
|
|
81
|
+
violation.rule,
|
|
82
|
+
violation.entityId,
|
|
83
|
+
violation.source ?? "unknown-source",
|
|
84
|
+
violation.description,
|
|
85
|
+
];
|
|
86
|
+
if (violation.suggestion) {
|
|
87
|
+
parts.push(`Suggestion: ${violation.suggestion}`);
|
|
88
|
+
}
|
|
89
|
+
return `- ${parts.join(" | ")}`;
|
|
90
|
+
});
|
|
91
|
+
return `${violations.length} violations found\n${details.join("\n")}`;
|
|
92
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
import { analyzeChangedFileImpact, } from "kibi-cli/public/impact-diagnostics";
|
|
19
|
+
export function hasImpactOptions(args) {
|
|
20
|
+
return Boolean(args.includeImpactDiagnostics ||
|
|
21
|
+
args.staged ||
|
|
22
|
+
args.includeWorkingTreeDiff ||
|
|
23
|
+
(args.sourceFiles && args.sourceFiles.length > 0));
|
|
24
|
+
}
|
|
25
|
+
export function analyzeKbCheckImpact(workspaceRoot, args) {
|
|
26
|
+
if (!hasImpactOptions(args)) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return analyzeChangedFileImpact({
|
|
30
|
+
workspaceRoot,
|
|
31
|
+
...(args.sourceFiles !== undefined
|
|
32
|
+
? { sourceFiles: args.sourceFiles }
|
|
33
|
+
: {}),
|
|
34
|
+
...(args.staged !== undefined ? { staged: args.staged } : {}),
|
|
35
|
+
...(args.includeWorkingTreeDiff !== undefined
|
|
36
|
+
? { includeWorkingTreeDiff: args.includeWorkingTreeDiff }
|
|
37
|
+
: {}),
|
|
38
|
+
...(args.includeImpactDiagnostics !== undefined
|
|
39
|
+
? { includeImpactDiagnostics: args.includeImpactDiagnostics }
|
|
40
|
+
: {}),
|
|
41
|
+
...(args.maxDiagnostics !== undefined
|
|
42
|
+
? { maxDiagnostics: args.maxDiagnostics }
|
|
43
|
+
: {}),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { resolveCorePlPath } from "./core-module.js";
|
|
2
|
+
import { collectQueryPlanSafetyViolations } from "./query-plan-safety.js";
|
|
3
|
+
// implements REQ-002
|
|
4
|
+
export async function runAggregatedChecks(prolog, rulesAllowlist, requireAdr) {
|
|
5
|
+
const violations = [];
|
|
6
|
+
const checksPlPath = resolveCorePlPath("checks.pl");
|
|
7
|
+
if (rulesAllowlist.has("query-plan-safety")) {
|
|
8
|
+
violations.push(...collectQueryPlanSafetyViolations(checksPlPath));
|
|
9
|
+
}
|
|
10
|
+
const normalizedChecksPlPath = checksPlPath.replace(/\\/g, "/");
|
|
11
|
+
const checksPlPathEscaped = normalizedChecksPlPath.replace(/'/g, "''");
|
|
12
|
+
const requireAdrStr = requireAdr ? "true" : "false";
|
|
13
|
+
const query = `(load_files('${checksPlPathEscaped}', [if(changed)]),
|
|
14
|
+
( predicate_property(checks:check_all_json_with_options(_, _), _)
|
|
15
|
+
-> call(checks:check_all_json_with_options(JsonString, ${requireAdrStr}))
|
|
16
|
+
; call(checks:check_all_json(JsonString))
|
|
17
|
+
))`;
|
|
18
|
+
const result = await prolog.query(query);
|
|
19
|
+
if (!result.success) {
|
|
20
|
+
throw new Error(`Aggregated checks query failed: ${result.error || "Unknown error"}`);
|
|
21
|
+
}
|
|
22
|
+
const violationsDict = parseViolations(result.bindings.JsonString);
|
|
23
|
+
for (const ruleViolations of Object.values(violationsDict)) {
|
|
24
|
+
for (const v of ruleViolations) {
|
|
25
|
+
const isAllowed = rulesAllowlist.has(v.rule);
|
|
26
|
+
if (isAllowed) {
|
|
27
|
+
violations.push({
|
|
28
|
+
rule: v.rule,
|
|
29
|
+
entityId: v.entityId,
|
|
30
|
+
description: v.description,
|
|
31
|
+
...(v.suggestion ? { suggestion: v.suggestion } : {}),
|
|
32
|
+
...(v.source ? { source: v.source } : {}),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return violations;
|
|
38
|
+
}
|
|
39
|
+
function parseViolations(jsonString) {
|
|
40
|
+
try {
|
|
41
|
+
if (!jsonString || typeof jsonString !== "string") {
|
|
42
|
+
throw new Error("No JSON string in binding");
|
|
43
|
+
}
|
|
44
|
+
const parsed = JSON.parse(jsonString);
|
|
45
|
+
const violations = typeof parsed === "string" ? JSON.parse(parsed) : parsed;
|
|
46
|
+
return violations;
|
|
47
|
+
}
|
|
48
|
+
catch (parseError) {
|
|
49
|
+
throw new Error(`Failed to parse violations JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/tools/check.js
CHANGED
|
@@ -1,99 +1,24 @@
|
|
|
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
|
-
import { readFile } from "node:fs/promises";
|
|
19
|
-
import * as path from "node:path";
|
|
20
|
-
import { DEFAULT_CHECKS_CONFIG, RULE_NAMES, } from "kibi-cli/public/check-types";
|
|
1
|
+
import { collectFullKbQualityDiagnostics, } from "kibi-cli/public/impact-diagnostics";
|
|
21
2
|
import { resolveWorkspaceRoot } from "../workspace.js";
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
...(d.suggestion !== undefined ? { suggestion: d.suggestion } : {}),
|
|
30
|
-
}));
|
|
31
|
-
}
|
|
32
|
-
function formatViolationText(violations) {
|
|
33
|
-
if (violations.length === 0) {
|
|
34
|
-
return "No violations found";
|
|
35
|
-
}
|
|
36
|
-
const details = violations.map((violation) => {
|
|
37
|
-
const parts = [
|
|
38
|
-
violation.rule,
|
|
39
|
-
violation.entityId,
|
|
40
|
-
violation.source ?? "unknown-source",
|
|
41
|
-
violation.description,
|
|
42
|
-
];
|
|
43
|
-
if (violation.suggestion) {
|
|
44
|
-
parts.push(`Suggestion: ${violation.suggestion}`);
|
|
45
|
-
}
|
|
46
|
-
return `- ${parts.join(" | ")}`;
|
|
47
|
-
});
|
|
48
|
-
return `${violations.length} violations found\n${details.join("\n")}`;
|
|
49
|
-
}
|
|
50
|
-
// implements REQ-002
|
|
51
|
-
async function loadChecksConfig(workspaceRoot) {
|
|
52
|
-
const configPath = path.join(workspaceRoot, ".kb", "config.json");
|
|
53
|
-
try {
|
|
54
|
-
const content = await readFile(configPath, "utf8");
|
|
55
|
-
const parsed = JSON.parse(content);
|
|
56
|
-
const parsedRules = parsed.checks?.rules;
|
|
57
|
-
const normalizedRules = {
|
|
58
|
-
...DEFAULT_CHECKS_CONFIG.rules,
|
|
59
|
-
};
|
|
60
|
-
if (parsedRules && typeof parsedRules === "object") {
|
|
61
|
-
for (const [key, value] of Object.entries(parsedRules)) {
|
|
62
|
-
if (typeof value === "boolean") {
|
|
63
|
-
normalizedRules[key] = value;
|
|
64
|
-
}
|
|
65
|
-
// Ignore non-boolean values (they are not added to normalizedRules, preserving defaults)
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
const parsedSt = parsed.checks?.symbolTraceability;
|
|
69
|
-
const normalizedSt = { ...DEFAULT_CHECKS_CONFIG.symbolTraceability };
|
|
70
|
-
if (parsedSt && typeof parsedSt === "object") {
|
|
71
|
-
if (typeof parsedSt.requireAdr === "boolean") {
|
|
72
|
-
normalizedSt.requireAdr = parsedSt.requireAdr;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return {
|
|
76
|
-
rules: normalizedRules,
|
|
77
|
-
symbolTraceability: normalizedSt,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
return DEFAULT_CHECKS_CONFIG;
|
|
3
|
+
import { getEffectiveRules, loadChecksConfig } from "./check-config.js";
|
|
4
|
+
import { buildStructuredContent, formatImpactText, formatQualityDiagnosticsText, formatViolationText, } from "./check-format.js";
|
|
5
|
+
import { analyzeKbCheckImpact } from "./check-impact.js";
|
|
6
|
+
import { runAggregatedChecks } from "./check-prolog.js";
|
|
7
|
+
function qualityDiagnosticsFromImpact(impactResult) {
|
|
8
|
+
if (impactResult === undefined) {
|
|
9
|
+
return [];
|
|
82
10
|
}
|
|
11
|
+
return impactResult.impactDiagnostics.filter((diagnostic) => !diagnostic.blocking && diagnostic.severity !== "error");
|
|
83
12
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
13
|
+
function buildSummary(input) {
|
|
14
|
+
const sections = [formatViolationText(input.violations)];
|
|
15
|
+
if (input.impactResult !== undefined) {
|
|
16
|
+
sections.push(formatImpactText(input.impactResult));
|
|
88
17
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const enabled = configRules[rule] ?? true;
|
|
92
|
-
if (enabled) {
|
|
93
|
-
effective.add(rule);
|
|
94
|
-
}
|
|
18
|
+
if (input.qualityDiagnostics.length > 0) {
|
|
19
|
+
sections.push(formatQualityDiagnosticsText(input.qualityDiagnostics));
|
|
95
20
|
}
|
|
96
|
-
return
|
|
21
|
+
return sections.join("\n");
|
|
97
22
|
}
|
|
98
23
|
/**
|
|
99
24
|
* Handle kb_check tool calls - run validation rules on the KB
|
|
@@ -102,18 +27,41 @@ function getEffectiveRules(configRules, requestedRules) {
|
|
|
102
27
|
// implements REQ-002
|
|
103
28
|
export async function handleKbCheck(prolog, args) {
|
|
104
29
|
const { rules, workspaceRoot: workspaceOverride } = args;
|
|
30
|
+
const hasExplicitRules = rules !== undefined;
|
|
105
31
|
try {
|
|
106
32
|
const workspaceRoot = workspaceOverride ?? resolveWorkspaceRoot();
|
|
107
33
|
const checksConfig = await loadChecksConfig(workspaceRoot);
|
|
108
34
|
const rulesAllowlist = getEffectiveRules(checksConfig.rules, rules);
|
|
35
|
+
const impactResult = analyzeKbCheckImpact(workspaceRoot, args);
|
|
36
|
+
const impactQualityDiagnostics = qualityDiagnosticsFromImpact(impactResult);
|
|
109
37
|
if (rulesAllowlist.size === 0) {
|
|
38
|
+
const qualityDiagnostics = hasExplicitRules
|
|
39
|
+
? []
|
|
40
|
+
: impactResult
|
|
41
|
+
? impactQualityDiagnostics
|
|
42
|
+
: await collectFullKbQualityDiagnostics({
|
|
43
|
+
prolog,
|
|
44
|
+
...(args.maxDiagnostics !== undefined
|
|
45
|
+
? { maxDiagnostics: args.maxDiagnostics }
|
|
46
|
+
: {}),
|
|
47
|
+
});
|
|
110
48
|
return {
|
|
111
|
-
content: [
|
|
112
|
-
|
|
49
|
+
content: [
|
|
50
|
+
{
|
|
51
|
+
type: "text",
|
|
52
|
+
text: buildSummary({
|
|
53
|
+
violations: [],
|
|
54
|
+
impactResult,
|
|
55
|
+
qualityDiagnostics,
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
structuredContent: buildStructuredContent({
|
|
113
60
|
violations: [],
|
|
114
|
-
count: 0,
|
|
115
61
|
diagnostics: [],
|
|
116
|
-
|
|
62
|
+
qualityDiagnostics,
|
|
63
|
+
impactResult,
|
|
64
|
+
}),
|
|
117
65
|
};
|
|
118
66
|
}
|
|
119
67
|
// Ensure we read the latest KB state, not a cached snapshot.
|
|
@@ -128,7 +76,22 @@ export async function handleKbCheck(prolog, args) {
|
|
|
128
76
|
...(v.source !== undefined ? { file: v.source } : {}),
|
|
129
77
|
...(v.suggestion !== undefined ? { suggestion: v.suggestion } : {}),
|
|
130
78
|
}));
|
|
131
|
-
const
|
|
79
|
+
const qualityDiagnostics = hasExplicitRules
|
|
80
|
+
? impactQualityDiagnostics
|
|
81
|
+
: impactResult
|
|
82
|
+
? impactQualityDiagnostics
|
|
83
|
+
: await collectFullKbQualityDiagnostics({
|
|
84
|
+
prolog,
|
|
85
|
+
hardViolationEntityIds: new Set(aggregatedViolations.map((violation) => violation.entityId)),
|
|
86
|
+
...(args.maxDiagnostics !== undefined
|
|
87
|
+
? { maxDiagnostics: args.maxDiagnostics }
|
|
88
|
+
: {}),
|
|
89
|
+
});
|
|
90
|
+
const summary = buildSummary({
|
|
91
|
+
violations: aggregatedViolations,
|
|
92
|
+
impactResult,
|
|
93
|
+
qualityDiagnostics,
|
|
94
|
+
});
|
|
132
95
|
return {
|
|
133
96
|
content: [
|
|
134
97
|
{
|
|
@@ -136,11 +99,12 @@ export async function handleKbCheck(prolog, args) {
|
|
|
136
99
|
text: summary,
|
|
137
100
|
},
|
|
138
101
|
],
|
|
139
|
-
structuredContent: {
|
|
102
|
+
structuredContent: buildStructuredContent({
|
|
140
103
|
violations: aggregatedViolations,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
104
|
+
diagnostics,
|
|
105
|
+
qualityDiagnostics,
|
|
106
|
+
impactResult,
|
|
107
|
+
}),
|
|
144
108
|
};
|
|
145
109
|
}
|
|
146
110
|
catch (error) {
|
|
@@ -148,51 +112,3 @@ export async function handleKbCheck(prolog, args) {
|
|
|
148
112
|
throw new Error(`Check execution failed: ${message}`);
|
|
149
113
|
}
|
|
150
114
|
}
|
|
151
|
-
// implements REQ-002
|
|
152
|
-
async function runAggregatedChecks(prolog, rulesAllowlist, requireAdr) {
|
|
153
|
-
const violations = [];
|
|
154
|
-
const checksPlPath = resolveCorePlPath("checks.pl");
|
|
155
|
-
const normalizedChecksPlPath = checksPlPath.replace(/\\/g, "/");
|
|
156
|
-
const checksPlPathEscaped = normalizedChecksPlPath.replace(/'/g, "''");
|
|
157
|
-
// Use check_all_json_with_options if available, otherwise fall back to check_all_json
|
|
158
|
-
const requireAdrStr = requireAdr ? "true" : "false";
|
|
159
|
-
const query = `(use_module('${checksPlPathEscaped}'),
|
|
160
|
-
( predicate_property(checks:check_all_json_with_options(_, _), _)
|
|
161
|
-
-> call(checks:check_all_json_with_options(JsonString, ${requireAdrStr}))
|
|
162
|
-
; call(checks:check_all_json(JsonString))
|
|
163
|
-
))`;
|
|
164
|
-
const result = await prolog.query(query);
|
|
165
|
-
if (!result.success) {
|
|
166
|
-
throw new Error(`Aggregated checks query failed: ${result.error || "Unknown error"}`);
|
|
167
|
-
}
|
|
168
|
-
let violationsDict;
|
|
169
|
-
try {
|
|
170
|
-
const jsonString = result.bindings.JsonString;
|
|
171
|
-
if (!jsonString) {
|
|
172
|
-
throw new Error("No JSON string in binding");
|
|
173
|
-
}
|
|
174
|
-
let parsed = JSON.parse(jsonString);
|
|
175
|
-
if (typeof parsed === "string") {
|
|
176
|
-
parsed = JSON.parse(parsed);
|
|
177
|
-
}
|
|
178
|
-
violationsDict = parsed;
|
|
179
|
-
}
|
|
180
|
-
catch (parseError) {
|
|
181
|
-
throw new Error(`Failed to parse violations JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
|
182
|
-
}
|
|
183
|
-
for (const ruleViolations of Object.values(violationsDict)) {
|
|
184
|
-
for (const v of ruleViolations) {
|
|
185
|
-
const isAllowed = rulesAllowlist.has(v.rule);
|
|
186
|
-
if (isAllowed) {
|
|
187
|
-
violations.push({
|
|
188
|
-
rule: v.rule,
|
|
189
|
-
entityId: v.entityId,
|
|
190
|
-
description: v.description,
|
|
191
|
-
...(v.suggestion ? { suggestion: v.suggestion } : {}),
|
|
192
|
-
...(v.source ? { source: v.source } : {}),
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return violations;
|
|
198
|
-
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
const GENERATOR_PATTERN = /\b(?:kb_entity|kb_relationship|member|memberchk|findall|setof|bagof)\s*\(/;
|
|
3
|
+
const NEGATION_PATTERN = /\\\+\s*/;
|
|
4
|
+
export function collectQueryPlanSafetyViolations(checksPlPath) {
|
|
5
|
+
const source = fs.readFileSync(checksPlPath, "utf8");
|
|
6
|
+
return analyzeSource(source).map((violation) => ({
|
|
7
|
+
rule: "query-plan-safety",
|
|
8
|
+
entityId: violation.predicate,
|
|
9
|
+
description: violation.description,
|
|
10
|
+
suggestion: violation.suggestion,
|
|
11
|
+
source: `${checksPlPath}:${violation.line}`,
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
function analyzeSource(source) {
|
|
15
|
+
const lines = source.split(/\r?\n/);
|
|
16
|
+
const violations = [];
|
|
17
|
+
let clauseStart = 0;
|
|
18
|
+
let predicate = "unknown";
|
|
19
|
+
let clauseLines = [];
|
|
20
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
21
|
+
const line = lines[index] ?? "";
|
|
22
|
+
if (clauseLines.length === 0 && line.trim().length === 0) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (clauseLines.length === 0) {
|
|
26
|
+
clauseStart = index + 1;
|
|
27
|
+
predicate = predicateNameFrom(line) ?? "unknown";
|
|
28
|
+
}
|
|
29
|
+
clauseLines.push(line);
|
|
30
|
+
if (line.trim().endsWith(".")) {
|
|
31
|
+
const violation = analyzeClause(predicate, clauseStart, clauseLines);
|
|
32
|
+
if (violation)
|
|
33
|
+
violations.push(violation);
|
|
34
|
+
clauseLines = [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return violations;
|
|
38
|
+
}
|
|
39
|
+
function predicateNameFrom(line) {
|
|
40
|
+
const match = line.match(/^\s*([a-z][a-zA-Z0-9_]*)\s*\(/);
|
|
41
|
+
return match?.[1] ?? null;
|
|
42
|
+
}
|
|
43
|
+
function analyzeClause(predicate, startLine, lines) {
|
|
44
|
+
const negationIndex = lines.findIndex((line) => NEGATION_PATTERN.test(line));
|
|
45
|
+
if (negationIndex < 0)
|
|
46
|
+
return null;
|
|
47
|
+
const hasLaterGenerator = lines
|
|
48
|
+
.slice(negationIndex + 1)
|
|
49
|
+
.some((line) => GENERATOR_PATTERN.test(line));
|
|
50
|
+
if (!hasLaterGenerator)
|
|
51
|
+
return null;
|
|
52
|
+
return {
|
|
53
|
+
predicate,
|
|
54
|
+
line: startLine + negationIndex,
|
|
55
|
+
description: "Negation appears before later generator calls in the same clause.",
|
|
56
|
+
suggestion: "Move kb_entity/kb_relationship/member/findall generators before \\+/1 so variables are bound before negation.",
|
|
57
|
+
};
|
|
58
|
+
}
|