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.
@@ -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 {};
@@ -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 { resolveCorePlPath } from "./core-module.js";
23
- function formatDiagnosticsForMcp(diagnostics) {
24
- return diagnostics.map((d) => ({
25
- category: d.category,
26
- severity: d.severity,
27
- message: d.message,
28
- ...(d.file !== undefined ? { file: d.file } : {}),
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
- // implements REQ-002
85
- function getEffectiveRules(configRules, requestedRules) {
86
- if (requestedRules && requestedRules.length > 0) {
87
- return new Set(requestedRules.filter((rule) => RULE_NAMES.has(rule)));
13
+ function buildSummary(input) {
14
+ const sections = [formatViolationText(input.violations)];
15
+ if (input.impactResult !== undefined) {
16
+ sections.push(formatImpactText(input.impactResult));
88
17
  }
89
- const effective = new Set();
90
- for (const rule of RULE_NAMES) {
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 effective;
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: [{ type: "text", text: "No violations found" }],
112
- structuredContent: {
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 summary = formatViolationText(aggregatedViolations);
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
- count: aggregatedViolations.length,
142
- diagnostics: formatDiagnosticsForMcp(diagnostics),
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
+ }