kibi-core 0.1.4 → 0.1.6

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 +1 -1
  2. package/src/checks.pl +395 -0
  3. package/src/kb.pl +81 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-core",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "description": "Core Prolog modules and RDF graph logic for Kibi",
6
6
  "type": "module",
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
@@ -59,6 +59,7 @@ kb_uri('urn-kibi:').
59
59
  :- dynamic kb_attached/1.
60
60
  :- dynamic kb_audit_db/1.
61
61
  :- dynamic kb_graph/1.
62
+ :- dynamic entity/4. % Support legacy .pl file format (Type, Id, Title, Props)
62
63
 
63
64
  %% kb_attach(+Directory)
64
65
  % Attach to a KB directory with RDF persistence and file locking.
@@ -97,7 +98,11 @@ kb_attach(Directory) :-
97
98
  % Track attachment state
98
99
  assert(kb_attached(Directory)),
99
100
  assert(kb_audit_db(AuditLog)),
100
- assert(kb_graph(GraphURI)).
101
+ assert(kb_graph(GraphURI)),
102
+
103
+ % Load legacy .pl entity files if present
104
+ load_kb_pl_files(Directory).
105
+
101
106
 
102
107
  %% kb_detach
103
108
  % Safely detach from KB, flushing journals and closing audit log.
@@ -142,6 +147,25 @@ kb_save :-
142
147
  with_kb_mutex(Goal) :-
143
148
  with_mutex(kb_lock, Goal).
144
149
 
150
+ %% load_kb_pl_files(+Directory)
151
+ % Load legacy .pl entity files from the KB directory.
152
+ % These files use entity/4 format: entity(Type, Id, Title, Props).
153
+ load_kb_pl_files(Directory) :-
154
+ retractall(entity(_, _, _, _)),
155
+ directory_files(Directory, Files),
156
+ forall(
157
+ (
158
+ member(File, Files),
159
+ sub_atom(File, _, 3, 0, '.pl'),
160
+ \+ memberchk(File, ['entities.pl', 'relationships.pl', 'validation.pl'])
161
+ ),
162
+ (
163
+ atom_concat(Directory, '/', Prefix),
164
+ atom_concat(Prefix, File, FullPath),
165
+ catch(consult(FullPath), Error, (print_message(warning, Error), fail))
166
+ )
167
+ ).
168
+
145
169
  %% kb_assert_entity(+Type, +Properties)
146
170
  % Assert an entity into the KB with validation and audit logging.
147
171
  % Properties is a list of Key=Value pairs.
@@ -209,7 +233,25 @@ kb_entity(Id, Type, Props) :-
209
233
  PropURI \= TypeURI,
210
234
  uri_to_key(PropURI, Key),
211
235
  literal_to_value(ValueLiteral, Value)
212
- ), Props).
236
+ ), Props).
237
+
238
+ % Fallback: read from legacy entity/4 facts loaded from .pl files
239
+ kb_entity(Id, Type, Props) :-
240
+ entity(Type, Id, _Title, PropList),
241
+ convert_legacy_props(PropList, Props).
242
+
243
+ % Convert legacy property list format to Key=Value pairs
244
+ convert_legacy_props([], []).
245
+ convert_legacy_props([Prop|Rest], [Key=Value|OutRest]) :-
246
+ convert_legacy_prop(Prop, Key, Value),
247
+ convert_legacy_props(Rest, OutRest).
248
+
249
+ convert_legacy_prop(Prop, Key, Value) :-
250
+ functor(Prop, Key, 1), !,
251
+ arg(1, Prop, Value).
252
+ convert_legacy_prop(Key-Value, Key, Value) :- !.
253
+ convert_legacy_prop(Key=Value, Key, Value) :- !.
254
+ convert_legacy_prop(Prop, Prop, true).
213
255
 
214
256
  %% kb_entities_by_source(+SourcePath, -Ids)
215
257
  % Returns all entity IDs whose source property matches SourcePath (substring match).
@@ -614,3 +656,40 @@ coerce_timestamp_atom(Val, Atom) :-
614
656
  coerce_timestamp_atom(Val, Atom) :-
615
657
  term_string(Val, Str),
616
658
  atom_string(Atom, Str).
659
+
660
+
661
+ %% Staged symbol traceability predicates
662
+ %% These support the pre-commit traceability gate feature
663
+
664
+ %% Dynamic declarations for overlay facts
665
+ :- dynamic changed_symbol/1.
666
+ :- dynamic changed_symbol_loc/5.
667
+ :- dynamic changed_symbol_req/2.
668
+
669
+ %% changed_symbol_missing_req(+Symbol, +MinLinks, -Count)
670
+ % True if Symbol has fewer than MinLinks requirement connections.
671
+ % changed_symbol_req/2 overlay facts (from code-comment directives) are also
672
+ % counted so that `// implements: REQ-001` can satisfy the gate.
673
+ changed_symbol_missing_req(Symbol, MinLinks, Count) :-
674
+ changed_symbol(Symbol),
675
+ ( setof(Req, transitively_implements(Symbol, Req), KbReqs)
676
+ -> true
677
+ ; KbReqs = []
678
+ ),
679
+ ( setof(Req, changed_symbol_req(Symbol, Req), OverlayReqs)
680
+ -> true
681
+ ; OverlayReqs = []
682
+ ),
683
+ append(KbReqs, OverlayReqs, AllReqs),
684
+ sort(AllReqs, UniqueReqs),
685
+ length(UniqueReqs, Count),
686
+ Count < MinLinks.
687
+
688
+ %% changed_symbol_violation(+Symbol, +MinLinks, -Count, -File, -Line, -Col, -Name)
689
+ % Full violation record for a changed symbol missing requirements.
690
+ changed_symbol_violation(Symbol, MinLinks, Count, File, Line, Col, Name) :-
691
+ changed_symbol_missing_req(Symbol, MinLinks, Count),
692
+ ( changed_symbol_loc(Symbol, FileRaw, Line, Col, NameRaw)
693
+ -> File = FileRaw, Name = NameRaw
694
+ ; File = '', Line = 0, Col = 0, Name = ''
695
+ ).