kibi-core 0.1.5 → 0.1.7

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.
Files changed (3) hide show
  1. package/package.json +5 -2
  2. package/src/checks.pl +395 -0
  3. package/src/kb.pl +49 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-core",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "description": "Core Prolog modules and RDF graph logic for Kibi",
6
6
  "type": "module",
@@ -9,7 +9,10 @@
9
9
  "bun": ">=1.0"
10
10
  },
11
11
  "main": "./src/kb.pl",
12
- "files": ["src/**/*.pl", "schema/**/*.pl"],
12
+ "files": [
13
+ "src/**/*.pl",
14
+ "schema/**/*.pl"
15
+ ],
13
16
  "license": "AGPL-3.0-or-later",
14
17
  "author": "Piotr Franczyk",
15
18
  "repository": {
package/src/checks.pl ADDED
@@ -0,0 +1,395 @@
1
+ % Module: checks
2
+ % Aggregated validation checks that return all violations in bulk
3
+ % This module provides predicates that compute all violations for a given
4
+ % validation rule in a single Prolog call, avoiding expensive round-trips.
5
+
6
+ :- module(checks, [
7
+ check_all/1, % Returns all violations as a dict
8
+ check_all_json/1, % Returns all violations as JSON string
9
+ check_must_priority_coverage/1, % Returns list of must-priority violations
10
+ check_symbol_coverage/1, % Returns list of uncovered symbols
11
+ check_no_dangling_refs/1, % Returns list of dangling ref violations
12
+ check_no_cycles/1, % Returns list of cycle violations
13
+ check_required_fields/1, % Returns list of missing required field violations
14
+ check_deprecated_adrs/1, % Returns list of deprecated ADR violations
15
+ check_domain_contradictions/1, % Returns list of contradiction violations
16
+ run_checks_json/0 % Entry point for JSON output
17
+ ]).
18
+
19
+ :- use_module(library(http/json)).
20
+ :- use_module(library(http/json_convert)).
21
+ :- use_module('kb.pl').
22
+ :- use_module('../schema/entities.pl', [entity_type/1, required_property/2]).
23
+ :- use_module('../schema/relationships.pl', [relationship_type/1]).
24
+
25
+ % Required fields for all entities
26
+ required_fields([id, title, status, created_at, updated_at, source]).
27
+
28
+ % Relationship types to check for dangling references
29
+ all_relationship_types([
30
+ depends_on, verified_by, validates, specified_by,
31
+ constrains, requires_property, supersedes, relates_to
32
+ ]).
33
+
34
+ %% check_all(-ViolationsDict)
35
+ % Returns a dict with all violations grouped by rule type.
36
+ % Each value is a list of violation terms: violation(Rule, EntityId, Description, Suggestion, Source)
37
+ check_all(ViolationsDict) :-
38
+ check_must_priority_coverage(MustPriority),
39
+ check_symbol_coverage(SymbolCoverage),
40
+ check_no_dangling_refs(DanglingRefs),
41
+ check_no_cycles(Cycles),
42
+ check_required_fields(RequiredFields),
43
+ check_deprecated_adrs(DeprecatedADRs),
44
+ check_domain_contradictions(Contradictions),
45
+ ViolationsDict = _{
46
+ must_priority_coverage: MustPriority,
47
+ symbol_coverage: SymbolCoverage,
48
+ no_dangling_refs: DanglingRefs,
49
+ no_cycles: Cycles,
50
+ required_fields: RequiredFields,
51
+ deprecated_adr_no_successor: DeprecatedADRs,
52
+ domain_contradictions: Contradictions
53
+ }.
54
+
55
+ %% check_must_priority_coverage(-Violations)
56
+ % Finds all must-priority requirements lacking scenario and/or test coverage.
57
+ % Returns list of violation/5 terms.
58
+ check_must_priority_coverage(Violations) :-
59
+ findall(
60
+ Violation,
61
+ coverage_gap_violation(Violation),
62
+ Violations
63
+ ).
64
+
65
+ coverage_gap_violation(violation(
66
+ 'must-priority-coverage',
67
+ ReqId,
68
+ Description,
69
+ Suggestion,
70
+ Source
71
+ )) :-
72
+ coverage_gap(ReqId, Reason),
73
+ coverage_gap_desc(Reason, Description),
74
+ coverage_gap_suggestion(Reason, Suggestion),
75
+ violation_source(ReqId, req, Source).
76
+
77
+ coverage_gap_desc(missing_test, "Must-priority requirement lacks test coverage").
78
+ coverage_gap_desc(missing_scenario, "Must-priority requirement lacks scenario coverage").
79
+ coverage_gap_desc(missing_scenario_and_test, "Must-priority requirement lacks scenario and test coverage").
80
+
81
+ coverage_gap_suggestion(missing_test, "Create test that validates this requirement").
82
+ coverage_gap_suggestion(missing_scenario, "Create scenario that specifies this requirement").
83
+ coverage_gap_suggestion(missing_scenario_and_test, "Create scenario that specifies and test that validates this requirement").
84
+
85
+ %% check_symbol_coverage(-Violations)
86
+ % Finds all symbols not traceable to any functional requirement.
87
+ check_symbol_coverage(Violations) :-
88
+ findall(SymbolId, symbol_no_req_coverage(SymbolId, _), SymbolIds0),
89
+ sort(SymbolIds0, SymbolIds),
90
+ maplist(symbol_coverage_violation, SymbolIds, Violations).
91
+
92
+ symbol_coverage_violation(SymbolId, violation(
93
+ 'symbol-coverage',
94
+ SymbolId,
95
+ "Code symbol is not traceable to any functional requirement.",
96
+ "Update symbols.yaml to link this symbol to a related requirement.",
97
+ Source
98
+ )) :-
99
+ violation_source(SymbolId, symbol, Source).
100
+
101
+ %% check_no_dangling_refs(-Violations)
102
+ % Finds all relationships referencing non-existent entities.
103
+ check_no_dangling_refs(Violations) :-
104
+ all_relationship_types(Types),
105
+ check_dangling_refs_for_types(Types, [], Violations).
106
+
107
+ check_dangling_refs_for_types([], Acc, Acc).
108
+ check_dangling_refs_for_types([Type|Rest], Acc, Violations) :-
109
+ findall(
110
+ Violation,
111
+ dangling_ref_violation(Type, Violation),
112
+ TypeViolations
113
+ ),
114
+ append(Acc, TypeViolations, NewAcc),
115
+ check_dangling_refs_for_types(Rest, NewAcc, Violations).
116
+
117
+ dangling_ref_violation(Type, violation(
118
+ 'no-dangling-refs',
119
+ FromId,
120
+ Description,
121
+ "Remove relationship or create missing entity",
122
+ ""
123
+ )) :-
124
+ kb_relationship(Type, FromId, ToId),
125
+ \+ kb_entity(FromId, _, _), % From doesn't exist
126
+ format(string(Description), "Relationship references non-existent entity: ~w", [FromId]).
127
+
128
+ dangling_ref_violation(Type, violation(
129
+ 'no-dangling-refs',
130
+ ToId,
131
+ Description,
132
+ "Remove relationship or create missing entity",
133
+ ""
134
+ )) :-
135
+ kb_relationship(Type, FromId, ToId),
136
+ kb_entity(FromId, _, _), % From exists
137
+ \+ kb_entity(ToId, _, _), % To doesn't exist
138
+ format(string(Description), "Relationship references non-existent entity: ~w", [ToId]).
139
+
140
+ %% check_no_cycles(-Violations)
141
+ % Finds circular dependencies in the depends_on graph.
142
+ check_no_cycles(Violations) :-
143
+ % Build adjacency list from depends_on relationships
144
+ findall(From-To, kb_relationship(depends_on, From, To), EdgePairs0),
145
+ sort(EdgePairs0, Edges),
146
+ cycle_start_nodes(Edges, Starts),
147
+
148
+ % Find at most one representative cycle per start node.
149
+ findall(
150
+ Cycle,
151
+ ( member(Start, Starts),
152
+ once(find_cycle_from_start(Edges, Start, Cycle))
153
+ ),
154
+ Cycles
155
+ ),
156
+
157
+ % Convert cycles to violations (only report first occurrence of each cycle)
158
+ cycles_to_violations(Cycles, [], Violations).
159
+
160
+ cycle_start_nodes(Edges, Starts) :-
161
+ findall(Start, member(Start-_, Edges), Starts0),
162
+ sort(Starts0, Starts).
163
+
164
+ find_cycle_from_start(Edges, Start, [Start, Start]) :-
165
+ memberchk(Start-Start, Edges),
166
+ !.
167
+ find_cycle_from_start(Edges, Start, Cycle) :-
168
+ dfs_cycle(Edges, Start, Start, [Start], Cycle).
169
+
170
+ dfs_cycle(Edges, Start, Current, Path, Cycle) :-
171
+ member(Current-Next, Edges),
172
+ ( Next = Start
173
+ -> length(Path, Len),
174
+ Len > 1,
175
+ reverse([Start|Path], Cycle)
176
+ ; \+ memberchk(Next, Path),
177
+ dfs_cycle(Edges, Start, Next, [Next|Path], Cycle)
178
+ ).
179
+
180
+ cycles_to_violations([], _, []).
181
+ cycles_to_violations([Cycle|Rest], Seen, [Violation|Violations]) :-
182
+ normalize_cycle(Cycle, Normalized),
183
+ \+ memberchk(Normalized, Seen),
184
+ !,
185
+ cycle_to_violation(Cycle, Violation),
186
+ cycles_to_violations(Rest, [Normalized|Seen], Violations).
187
+ cycles_to_violations([_|Rest], Seen, Violations) :-
188
+ cycles_to_violations(Rest, Seen, Violations).
189
+
190
+ normalize_cycle(Cycle, Normalized) :-
191
+ sort(Cycle, Normalized).
192
+
193
+ cycle_to_violation(Cycle, violation(
194
+ 'no-cycles',
195
+ FirstId,
196
+ Description,
197
+ "Break cycle by removing one of the depends_on relationships",
198
+ Source
199
+ )) :-
200
+ Cycle = [FirstId|_],
201
+
202
+ % Build cycle description with source names
203
+ findall(
204
+ Name,
205
+ ( member(Id, Cycle),
206
+ ( kb_entity(Id, _, Props),
207
+ memberchk(source=SourcePath0, Props)
208
+ -> normalize_term_atom(SourcePath0, SourcePath),
209
+ file_base_name(SourcePath, Name)
210
+ ; Name = Id
211
+ )
212
+ ),
213
+ Names
214
+ ),
215
+
216
+ % Join with arrows
217
+ atomic_list_concat(Names, ' → ', NamesStr),
218
+ format(string(Description), "Circular dependency detected: ~w", [NamesStr]),
219
+
220
+ % Get source of first entity
221
+ ( kb_entity(FirstId, _, Props),
222
+ memberchk(source=Source0, Props)
223
+ -> normalize_term_atom(Source0, Source)
224
+ ; Source = ""
225
+ ).
226
+
227
+ %% check_required_fields(-Violations)
228
+ % Finds all entities missing required fields.
229
+ check_required_fields(Violations) :-
230
+ required_fields(Required),
231
+ findall(
232
+ Violation,
233
+ missing_required_field(Required, Violation),
234
+ Violations
235
+ ).
236
+
237
+ missing_required_field(Required, violation(
238
+ 'required-fields',
239
+ EntityId,
240
+ Description,
241
+ Suggestion,
242
+ Source
243
+ )) :-
244
+ kb_entity(EntityId, Type, Props),
245
+ member(Field, Required),
246
+ \+ memberchk(Field=_, Props),
247
+
248
+ format(string(Description), "Missing required field: ~w", [Field]),
249
+ format(string(Suggestion), "Add ~w to entity definition", [Field]),
250
+
251
+ ( memberchk(source=Source, Props)
252
+ -> true
253
+ ; Source = ""
254
+ ).
255
+
256
+ %% check_deprecated_adrs(-Violations)
257
+ % Finds all deprecated ADRs without successors.
258
+ check_deprecated_adrs(Violations) :-
259
+ findall(
260
+ Violation,
261
+ deprecated_adr_violation(Violation),
262
+ Violations
263
+ ).
264
+
265
+ deprecated_adr_violation(violation(
266
+ 'deprecated-adr-no-successor',
267
+ AdrId,
268
+ Description,
269
+ Suggestion,
270
+ Source
271
+ )) :-
272
+ deprecated_no_successor(AdrId),
273
+
274
+ Description = "Archived/deprecated ADR has no successor — add a supersedes link from the replacement ADR",
275
+
276
+ format(string(Suggestion), "Create a new ADR and add: links: [{type: supersedes, target: ~w}]", [AdrId]),
277
+
278
+ ( kb_entity(AdrId, adr, Props),
279
+ memberchk(source=Source, Props)
280
+ -> true
281
+ ; Source = ""
282
+ ).
283
+
284
+ %% check_domain_contradictions(-Violations)
285
+ % Finds all pairs of requirements with contradicting required properties.
286
+ check_domain_contradictions(Violations) :-
287
+ findall(
288
+ violation(
289
+ 'domain-contradictions',
290
+ EntityId,
291
+ Description,
292
+ "Supersede one requirement or align both to the same required property",
293
+ ""
294
+ ),
295
+ ( contradicting_reqs(ReqA, ReqB, Reason),
296
+ format(string(EntityId), "~w/~w", [ReqA, ReqB]),
297
+ Description = Reason
298
+ ),
299
+ Violations
300
+ ).
301
+
302
+ %% run_checks_json
303
+ % Entry point for JSON output. Prints all violations as JSON to stdout.
304
+ run_checks_json :-
305
+ catch(
306
+ ( check_all(ViolationsDict),
307
+ json_write_dict(current_output, ViolationsDict, [width(0)]),
308
+ nl,
309
+ halt(0)
310
+ ),
311
+ Error,
312
+ ( format(user_error, '{"error": "~q"}~n', [Error]),
313
+ halt(1)
314
+ )
315
+ ).
316
+
317
+ % Alternative: return JSON as a string binding instead of writing to stdout
318
+ check_all_json(JsonString) :-
319
+ check_all(ViolationsDict),
320
+ violations_dict_to_json(ViolationsDict, JsonDict),
321
+ with_output_to_string(
322
+ json_write_dict(current_output, JsonDict, [width(0)]),
323
+ JsonString
324
+ ).
325
+
326
+ %% violations_dict_to_json(+ViolationsDict, -JsonDict)
327
+ % Converts a dict of violation/5 term lists to a dict of JSON-compatible dicts.
328
+ violations_dict_to_json(Dict, JsonDict) :-
329
+ dict_pairs(Dict, Tag, Pairs),
330
+ pairs_to_json_pairs(Pairs, JsonPairs),
331
+ dict_pairs(JsonDict, Tag, JsonPairs).
332
+
333
+ %% pairs_to_json_pairs(+Pairs, -JsonPairs)
334
+ % Converts a list of Key-Violations pairs to Key-JsonViolations pairs.
335
+ pairs_to_json_pairs([], []).
336
+ pairs_to_json_pairs([Key-Violations|Rest], [Key-JsonViolations|JsonRest]) :-
337
+ maplist(violation_to_json, Violations, JsonViolations),
338
+ pairs_to_json_pairs(Rest, JsonRest).
339
+
340
+ %% violation_to_json(+Violation, -JsonDict)
341
+ % Converts a violation(Rule, EntityId, Description, Suggestion, Source) term
342
+ % to a JSON-compatible dict.
343
+ violation_to_json(Violation, JsonDict) :-
344
+ violation_term_to_dict(Violation, JsonDict).
345
+
346
+ violation_term_to_dict(violation(Rule, EntityId, Description, Suggestion, Source), JsonDict) :-
347
+ violation_text(Rule, RuleText),
348
+ violation_id_text(EntityId, EntityIdText),
349
+ violation_text(Description, DescriptionText),
350
+ violation_text(Suggestion, SuggestionText),
351
+ violation_id_text(Source, SourceText),
352
+ JsonDict = _{rule: RuleText, entityId: EntityIdText, description: DescriptionText,
353
+ suggestion: SuggestionText, source: SourceText}.
354
+
355
+ violation_text(Val, Text) :-
356
+ nonvar(Val),
357
+ Val =.. ['^^', Inner, _Type],
358
+ !,
359
+ violation_text(Inner, Text).
360
+ violation_text(literal(type(_, Val)), Text) :-
361
+ !,
362
+ violation_text(Val, Text).
363
+ violation_text(Val, Val) :-
364
+ string(Val),
365
+ !.
366
+ violation_text(Val, Text) :-
367
+ atom(Val),
368
+ !,
369
+ atom_string(Val, Text).
370
+ violation_text(Val, Text) :-
371
+ term_string(Val, Text).
372
+
373
+ violation_id_text(Val, Text) :-
374
+ normalize_term_atom(Val, Atom),
375
+ atom_string(Atom, Text).
376
+
377
+ violation_source(EntityId, Type, Source) :-
378
+ ( kb_entity(EntityId, Type, Props),
379
+ memberchk(source=Source0, Props)
380
+ -> normalize_term_atom(Source0, Source)
381
+ ; Source = ""
382
+ ).
383
+
384
+ % Helper: capture output to string
385
+ with_output_to_string(Goal, String) :-
386
+ with_output_to(codes(Codes), Goal),
387
+ string_codes(String, Codes).
388
+
389
+ file_base_name(Path, Base) :-
390
+ normalize_term_atom(Path, PathAtom),
391
+ ( sub_atom(PathAtom, _, _, _, '/')
392
+ -> split_string(PathAtom, '/', '', Parts),
393
+ last(Parts, Base)
394
+ ; Base = PathAtom
395
+ ).
package/src/kb.pl CHANGED
@@ -110,6 +110,16 @@ kb_detach :-
110
110
  ( kb_attached(_Directory)
111
111
  -> (
112
112
  kb_save,
113
+ % Unload RDF graph from memory to prevent duplication on reattach
114
+ ( kb_graph(GraphURI)
115
+ -> rdf_unload_graph(GraphURI)
116
+ ; true
117
+ ),
118
+ % Sync and close audit log
119
+ ( kb_audit_db(AuditLog)
120
+ -> db_sync(AuditLog)
121
+ ; true
122
+ ),
113
123
  % Clear state
114
124
  retractall(kb_attached(_)),
115
125
  retractall(kb_audit_db(_)),
@@ -151,6 +161,8 @@ with_kb_mutex(Goal) :-
151
161
  % Load legacy .pl entity files from the KB directory.
152
162
  % These files use entity/4 format: entity(Type, Id, Title, Props).
153
163
  load_kb_pl_files(Directory) :-
164
+ catch(abolish(entity/4), _, true),
165
+ dynamic(entity/4),
154
166
  retractall(entity(_, _, _, _)),
155
167
  directory_files(Directory, Files),
156
168
  forall(
@@ -656,3 +668,40 @@ coerce_timestamp_atom(Val, Atom) :-
656
668
  coerce_timestamp_atom(Val, Atom) :-
657
669
  term_string(Val, Str),
658
670
  atom_string(Atom, Str).
671
+
672
+
673
+ %% Staged symbol traceability predicates
674
+ %% These support the pre-commit traceability gate feature
675
+
676
+ %% Dynamic declarations for overlay facts
677
+ :- dynamic changed_symbol/1.
678
+ :- dynamic changed_symbol_loc/5.
679
+ :- dynamic changed_symbol_req/2.
680
+
681
+ %% changed_symbol_missing_req(+Symbol, +MinLinks, -Count)
682
+ % True if Symbol has fewer than MinLinks requirement connections.
683
+ % changed_symbol_req/2 overlay facts (from code-comment directives) are also
684
+ % counted so that `// implements: REQ-001` can satisfy the gate.
685
+ changed_symbol_missing_req(Symbol, MinLinks, Count) :-
686
+ changed_symbol(Symbol),
687
+ ( setof(Req, transitively_implements(Symbol, Req), KbReqs)
688
+ -> true
689
+ ; KbReqs = []
690
+ ),
691
+ ( setof(Req, changed_symbol_req(Symbol, Req), OverlayReqs)
692
+ -> true
693
+ ; OverlayReqs = []
694
+ ),
695
+ append(KbReqs, OverlayReqs, AllReqs),
696
+ sort(AllReqs, UniqueReqs),
697
+ length(UniqueReqs, Count),
698
+ Count < MinLinks.
699
+
700
+ %% changed_symbol_violation(+Symbol, +MinLinks, -Count, -File, -Line, -Col, -Name)
701
+ % Full violation record for a changed symbol missing requirements.
702
+ changed_symbol_violation(Symbol, MinLinks, Count, File, Line, Col, Name) :-
703
+ changed_symbol_missing_req(Symbol, MinLinks, Count),
704
+ ( changed_symbol_loc(Symbol, FileRaw, Line, Col, NameRaw)
705
+ -> File = FileRaw, Name = NameRaw
706
+ ; File = '', Line = 0, Col = 0, Name = ''
707
+ ).