kibi-core 0.1.10 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kibi-core",
3
- "version": "0.1.10",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Core Prolog modules and RDF graph logic for Kibi",
6
6
  "type": "module",
@@ -0,0 +1,461 @@
1
+ % Module: discovery
2
+ % Curated discovery, reporting, and bounded graph traversal predicates.
3
+
4
+ :- module(discovery, [
5
+ find_gaps_json/8,
6
+ coverage_report_json/7,
7
+ graph_expand_json/8
8
+ ]).
9
+
10
+ :- use_module(library(http/json)).
11
+ :- use_module('kb.pl').
12
+ :- use_module('status.pl', [status_meta_dict/1]).
13
+ :- use_module('../schema/relationships.pl', [relationship_type/1]).
14
+
15
+ find_gaps_json(TypeFilter, MissingRelationships, PresentRelationships, Tags, SourceFilter, Limit, Offset, JsonString) :-
16
+ findall(
17
+ Row,
18
+ matching_gap_row(TypeFilter, MissingRelationships, PresentRelationships, Tags, SourceFilter, Row),
19
+ Rows0
20
+ ),
21
+ sort_dict_rows(Rows0, SortedRows),
22
+ paginate_rows(SortedRows, Offset, Limit, Rows),
23
+ length(SortedRows, Count),
24
+ status_meta_dict(Meta),
25
+ Response = _{rows: Rows, count: Count, meta: Meta},
26
+ dict_json_string(Response, JsonString).
27
+
28
+ coverage_report_json(By, Tags, IncludePassing, IncludeTransitive, Limit, Offset, JsonString) :-
29
+ coverage_rows(By, Tags, IncludePassing, IncludeTransitive, Rows0, Summary),
30
+ sort_dict_rows(Rows0, SortedRows),
31
+ paginate_rows(SortedRows, Offset, Limit, Rows),
32
+ status_meta_dict(Meta),
33
+ Response = _{summary: Summary, rows: Rows, meta: Meta},
34
+ dict_json_string(Response, JsonString).
35
+
36
+ graph_expand_json(SeedIds, Relationships, Direction, Depth, EntityTypes, MaxNodes, MaxEdges, JsonString) :-
37
+ sort(SeedIds, SeedSet),
38
+ bfs_layers(SeedSet, SeedSet, [], Relationships, Direction, Depth, MaxNodes, MaxEdges, SeenNodes0, SeenEdges0, Truncated0),
39
+ append(SeedSet, SeenNodes0, SeededNodes0),
40
+ sort(SeededNodes0, SeenNodes),
41
+ include(keep_entity_type(EntityTypes), SeenNodes, FilteredNodes0),
42
+ sort(FilteredNodes0, FilteredNodes),
43
+ include(edge_kept(FilteredNodes), SeenEdges0, FilteredEdges0),
44
+ sort(FilteredEdges0, FilteredEdges),
45
+ maplist(node_dict, FilteredNodes, Nodes),
46
+ maplist(edge_dict, FilteredEdges, Edges),
47
+ ( Truncated0
48
+ -> Truncated = true
49
+ ; Truncated = false
50
+ ),
51
+ status_meta_dict(Meta),
52
+ Response = _{nodes: Nodes, edges: Edges, truncated: Truncated, meta: Meta},
53
+ dict_json_string(Response, JsonString).
54
+
55
+ matching_gap_row(TypeFilter, MissingRelationships, PresentRelationships, Tags, SourceFilter, Row) :-
56
+ kb_entity(Id, Type, Props),
57
+ matches_type(TypeFilter, Type),
58
+ matches_tags(Tags, Props),
59
+ matches_source(SourceFilter, Props),
60
+ relationships_missing(Id, MissingRelationships),
61
+ relationships_present(Id, PresentRelationships),
62
+ entity_title_status_source(Props, Title, Status, Source),
63
+ relationship_counts(Id, Counts),
64
+ Row = _{
65
+ id: Id,
66
+ type: Type,
67
+ title: Title,
68
+ status: Status,
69
+ missingRelationships: MissingRelationships,
70
+ presentRelationships: PresentRelationships,
71
+ relationshipCounts: Counts,
72
+ source: Source
73
+ }.
74
+
75
+ relationships_missing(_Id, []).
76
+ relationships_missing(Id, [Relationship|Rest]) :-
77
+ relationship_count(Id, Relationship, 0),
78
+ relationships_missing(Id, Rest).
79
+
80
+ relationships_present(_Id, []).
81
+ relationships_present(Id, [Relationship|Rest]) :-
82
+ relationship_count(Id, Relationship, Count),
83
+ Count > 0,
84
+ relationships_present(Id, Rest).
85
+
86
+ relationship_counts(Id, CountsDict) :-
87
+ findall(Relationship-Count,
88
+ (relationship_type(Relationship), relationship_count(Id, Relationship, Count)),
89
+ Pairs),
90
+ dict_pairs(CountsDict, relationship_counts, Pairs).
91
+
92
+ relationship_count(Id, Relationship, Count) :-
93
+ findall(1,
94
+ (kb_relationship(Relationship, Id, _); kb_relationship(Relationship, _, Id)),
95
+ Matches),
96
+ length(Matches, Count).
97
+
98
+ coverage_rows(req, Tags, IncludePassing, IncludeTransitive, Rows, Summary) :-
99
+ !,
100
+ findall(Row,
101
+ requirement_coverage_row(Tags, IncludeTransitive, Row),
102
+ AllRows),
103
+ filter_req_coverage_rows(IncludePassing, AllRows, Rows),
104
+ coverage_summary(AllRows, Summary).
105
+ coverage_rows(symbol, Tags, IncludePassing, _IncludeTransitive, Rows, Summary) :-
106
+ !,
107
+ findall(Row,
108
+ symbol_coverage_row(Tags, Row),
109
+ AllRows),
110
+ filter_symbol_coverage_rows(IncludePassing, AllRows, Rows),
111
+ length(AllRows, Total),
112
+ include(symbol_row_fully_covered, AllRows, CoveredRows),
113
+ length(CoveredRows, Covered),
114
+ Uncovered is Total - Covered,
115
+ Summary = _{total: Total, fullyCovered: Covered, uncovered: Uncovered}.
116
+ coverage_rows(type, _Tags, _IncludePassing, _IncludeTransitive, Rows, Summary) :-
117
+ findall(Type-Count,
118
+ type_entity_count(Type, Count),
119
+ Pairs),
120
+ maplist(type_pair_row, Pairs, Rows),
121
+ length(Rows, Total),
122
+ Summary = _{total: Total}.
123
+
124
+ requirement_coverage_row(Tags, IncludeTransitive, Row) :-
125
+ kb_entity(Id, req, Props),
126
+ matches_tags(Tags, Props),
127
+ entity_title_status_source(Props, Title, Status, _Source),
128
+ entity_priority(Props, Priority),
129
+ count_distinct_targets(specified_by, Id, source, ScenarioCount),
130
+ requirement_test_count(Id, TestCount),
131
+ count_direct_symbols(Id, DirectSymbolCount),
132
+ count_transitive_symbols(Id, IncludeTransitive, TransitiveSymbolCount),
133
+ requirement_coverage_state(Id, Props, Gaps, Evaluated, CoverageStatus),
134
+ Row = _{
135
+ id: Id,
136
+ type: req,
137
+ title: Title,
138
+ status: Status,
139
+ priority: Priority,
140
+ scenarioCount: ScenarioCount,
141
+ testCount: TestCount,
142
+ directSymbolCount: DirectSymbolCount,
143
+ transitiveSymbolCount: TransitiveSymbolCount,
144
+ gaps: Gaps,
145
+ evaluated: Evaluated,
146
+ coverageStatus: CoverageStatus
147
+ }.
148
+
149
+ symbol_coverage_row(Tags, Row) :-
150
+ kb_entity(Id, symbol, Props),
151
+ matches_tags(Tags, Props),
152
+ entity_title_status_source(Props, Title, Status, _Source),
153
+ count_direct_requirements(Id, DirectRequirementCount),
154
+ count_direct_tests(Id, TestCount),
155
+ ( symbol_no_req_coverage(Id, _)
156
+ -> Gaps = [missing_requirement],
157
+ CoverageStatus = uncovered
158
+ ; Gaps = [],
159
+ CoverageStatus = fully_covered
160
+ ),
161
+ Row = _{
162
+ id: Id,
163
+ type: symbol,
164
+ title: Title,
165
+ status: Status,
166
+ directRequirementCount: DirectRequirementCount,
167
+ testCount: TestCount,
168
+ gaps: Gaps,
169
+ coverageStatus: CoverageStatus
170
+ }.
171
+
172
+ coverage_summary(Rows, Summary) :-
173
+ length(Rows, Total),
174
+ include(req_row_evaluated, Rows, EvaluatedRows),
175
+ include(req_row_fully_covered, Rows, FullyCoveredRows),
176
+ include(req_row_uncovered, Rows, UncoveredRows),
177
+ include(req_row_not_applicable, Rows, NotApplicableRows),
178
+ include(req_row_missing_scenario, Rows, MissingScenarioRows),
179
+ include(req_row_missing_test, Rows, MissingTestRows),
180
+ include(req_row_missing_both, Rows, MissingBothRows),
181
+ length(EvaluatedRows, Evaluated),
182
+ length(FullyCoveredRows, FullyCovered),
183
+ length(UncoveredRows, Uncovered),
184
+ length(NotApplicableRows, NotApplicable),
185
+ length(MissingScenarioRows, MissingScenario),
186
+ length(MissingTestRows, MissingTest),
187
+ length(MissingBothRows, MissingScenarioAndTest),
188
+ Summary = _{
189
+ total: Total,
190
+ evaluated: Evaluated,
191
+ fullyCovered: FullyCovered,
192
+ uncovered: Uncovered,
193
+ notApplicable: NotApplicable,
194
+ missingScenario: MissingScenario,
195
+ missingTest: MissingTest,
196
+ missingScenarioAndTest: MissingScenarioAndTest
197
+ }.
198
+
199
+ req_row_fully_covered(Row) :-
200
+ CoverageStatus = Row.get(coverageStatus),
201
+ CoverageStatus = fully_covered.
202
+ req_row_evaluated(Row) :-
203
+ Evaluated = Row.get(evaluated),
204
+ Evaluated = true.
205
+ req_row_uncovered(Row) :-
206
+ CoverageStatus = Row.get(coverageStatus),
207
+ CoverageStatus = uncovered.
208
+ req_row_not_applicable(Row) :-
209
+ CoverageStatus = Row.get(coverageStatus),
210
+ CoverageStatus = not_applicable.
211
+ req_row_missing_scenario(Row) :-
212
+ Row.gaps = [missing_scenario].
213
+ req_row_missing_test(Row) :-
214
+ Row.gaps = [missing_test].
215
+ req_row_missing_both(Row) :-
216
+ Row.gaps = [missing_scenario_and_test].
217
+
218
+ symbol_row_fully_covered(Row) :-
219
+ CoverageStatus = Row.get(coverageStatus),
220
+ CoverageStatus = fully_covered.
221
+
222
+ count_distinct_targets(Relationship, Id, source, Count) :-
223
+ !,
224
+ findall(Target, kb_relationship(Relationship, Id, Target), Targets0),
225
+ sort(Targets0, Targets),
226
+ length(Targets, Count).
227
+
228
+ requirement_test_count(Id, Count) :-
229
+ findall(TestId, kb_relationship(verified_by, Id, TestId), Verified0),
230
+ findall(TestId, kb_relationship(validates, TestId, Id), Validates0),
231
+ append(Verified0, Validates0, Combined0),
232
+ sort(Combined0, Combined),
233
+ length(Combined, Count).
234
+
235
+ count_direct_symbols(Id, Count) :-
236
+ findall(SymbolId, kb_relationship(implements, SymbolId, Id), Symbols0),
237
+ sort(Symbols0, Symbols),
238
+ length(Symbols, Count).
239
+
240
+ count_transitive_symbols(Id, true, Count) :-
241
+ !,
242
+ findall(SymbolId, transitively_implements(SymbolId, Id), Symbols0),
243
+ sort(Symbols0, Symbols),
244
+ length(Symbols, Count).
245
+ count_transitive_symbols(Id, false, Count) :-
246
+ count_direct_symbols(Id, Count).
247
+
248
+ count_direct_requirements(Id, Count) :-
249
+ findall(ReqId, kb_relationship(implements, Id, ReqId), ReqIds0),
250
+ sort(ReqIds0, ReqIds),
251
+ length(ReqIds, Count).
252
+
253
+ count_direct_tests(Id, Count) :-
254
+ findall(TestId, kb_relationship(covered_by, Id, TestId), TestIds0),
255
+ sort(TestIds0, TestIds),
256
+ length(TestIds, Count).
257
+
258
+ requirement_gap_list(Id, [Reason]) :-
259
+ coverage_gap(Id, Reason),
260
+ !.
261
+ requirement_gap_list(_, []).
262
+
263
+ requirement_coverage_state(Id, Props, Gaps, true, CoverageStatus) :-
264
+ entity_priority(Props, Priority),
265
+ must_priority(Priority),
266
+ !,
267
+ requirement_gap_list(Id, Gaps),
268
+ ( Gaps = []
269
+ -> CoverageStatus = fully_covered
270
+ ; CoverageStatus = uncovered
271
+ ).
272
+ requirement_coverage_state(_Id, _Props, [], false, not_applicable).
273
+
274
+ filter_req_coverage_rows(true, Rows, Rows).
275
+ filter_req_coverage_rows(false, Rows, Filtered) :-
276
+ exclude(req_row_fully_covered, Rows, Filtered).
277
+
278
+ filter_symbol_coverage_rows(true, Rows, Rows).
279
+ filter_symbol_coverage_rows(false, Rows, Filtered) :-
280
+ exclude(symbol_row_fully_covered, Rows, Filtered).
281
+
282
+ entity_priority(Props, Priority) :-
283
+ ( memberchk(priority=PriorityValue, Props)
284
+ -> source_value_atom(PriorityValue, Priority)
285
+ ; Priority = ''
286
+ ).
287
+
288
+ must_priority(Priority) :-
289
+ atom(Priority),
290
+ downcase_atom(Priority, Lowercase),
291
+ sub_atom(Lowercase, _, 4, 0, must).
292
+
293
+ type_entity_count(Type, Count) :-
294
+ setof(Id, Props^kb_entity(Id, Type, Props), Ids),
295
+ length(Ids, Count).
296
+
297
+ type_pair_row(Type-Count, _{id: Type, type: Type, count: Count}).
298
+
299
+ bfs_layers(_Frontier, SeenNodes, SeenEdges, _Relationships, _Direction, 0, _MaxNodes, _MaxEdges, SeenNodes, SeenEdges, false) :- !.
300
+ bfs_layers([], SeenNodes, SeenEdges, _Relationships, _Direction, _Depth, _MaxNodes, _MaxEdges, SeenNodes, SeenEdges, false) :- !.
301
+ bfs_layers(Frontier, SeenNodes, SeenEdges, Relationships, Direction, Depth, MaxNodes, MaxEdges, FinalNodes, FinalEdges, Truncated) :-
302
+ ( length(SeenNodes, NodeCount), NodeCount >= MaxNodes
303
+ -> FinalNodes = SeenNodes,
304
+ FinalEdges = SeenEdges,
305
+ Truncated = true
306
+ ; length(SeenEdges, EdgeCount), EdgeCount >= MaxEdges
307
+ -> FinalNodes = SeenNodes,
308
+ FinalEdges = SeenEdges,
309
+ Truncated = true
310
+ ; findall(Edge-Next,
311
+ (member(Current, Frontier), edge_step(Current, Relationships, Direction, Edge, Next)),
312
+ Pairs0),
313
+ sort(Pairs0, Pairs),
314
+ collect_pairs(Pairs, SeenNodes, SeenEdges, MaxNodes, MaxEdges, NextFrontier, NextNodes, NextEdges, HitLimit),
315
+ Depth1 is Depth - 1,
316
+ bfs_layers(NextFrontier, NextNodes, NextEdges, Relationships, Direction, Depth1, MaxNodes, MaxEdges, FinalNodes, FinalEdges, Truncated1),
317
+ ( HitLimit
318
+ -> Truncated = true
319
+ ; Truncated = Truncated1
320
+ )
321
+ ).
322
+
323
+ collect_pairs([], SeenNodes, SeenEdges, _MaxNodes, _MaxEdges, [], SeenNodes, SeenEdges, false).
324
+ collect_pairs([Edge-Next|Rest], SeenNodes, SeenEdges, MaxNodes, MaxEdges, Frontier, FinalNodes, FinalEdges, HitLimit) :-
325
+ ( memberchk(Edge, SeenEdges)
326
+ -> true,
327
+ collect_pairs(Rest, SeenNodes, SeenEdges, MaxNodes, MaxEdges, Frontier, FinalNodes, FinalEdges, HitLimit)
328
+ ; collect_new_pair(Edge, Next, Rest, SeenNodes, SeenEdges, MaxNodes, MaxEdges, Frontier, FinalNodes, FinalEdges, HitLimit)
329
+ ).
330
+
331
+ collect_new_pair(Edge, Next, _Rest, SeenNodes, SeenEdges, MaxNodes, MaxEdges, Frontier, FinalNodes, FinalEdges, HitLimit) :-
332
+ prepare_new_pair(Edge, Next, SeenNodes, SeenEdges, SeenNodes1, SeenEdges1, NewFrontier),
333
+ length(SeenNodes1, NodeCount),
334
+ length(SeenEdges1, EdgeCount),
335
+ ( NodeCount >= MaxNodes
336
+ ; EdgeCount >= MaxEdges
337
+ ),
338
+ !,
339
+ Frontier = NewFrontier,
340
+ FinalNodes = SeenNodes1,
341
+ FinalEdges = SeenEdges1,
342
+ HitLimit = true.
343
+ collect_new_pair(Edge, Next, Rest, SeenNodes, SeenEdges, MaxNodes, MaxEdges, Frontier, FinalNodes, FinalEdges, HitLimit) :-
344
+ prepare_new_pair(Edge, Next, SeenNodes, SeenEdges, SeenNodes1, SeenEdges1, NewFrontier),
345
+ collect_pairs(Rest, SeenNodes1, SeenEdges1, MaxNodes, MaxEdges, FrontierRest, FinalNodes, FinalEdges, HitLimitRest),
346
+ append(NewFrontier, FrontierRest, Frontier),
347
+ HitLimit = HitLimitRest.
348
+
349
+ prepare_new_pair(Edge, Next, SeenNodes, SeenEdges, SeenNodes1, SeenEdges1, NewFrontier) :-
350
+ append(SeenEdges, [Edge], SeenEdges1),
351
+ ( memberchk(Next, SeenNodes)
352
+ -> SeenNodes1 = SeenNodes,
353
+ NewFrontier = []
354
+ ; append(SeenNodes, [Next], SeenNodes1),
355
+ NewFrontier = [Next]
356
+ ).
357
+
358
+ edge_step(Current, Relationships, outgoing, edge(Type, Current, Next), Next) :-
359
+ relationship_allowed(Relationships, Type),
360
+ kb_relationship(Type, Current, Next).
361
+ edge_step(Current, Relationships, incoming, edge(Type, Prev, Current), Prev) :-
362
+ relationship_allowed(Relationships, Type),
363
+ kb_relationship(Type, Prev, Current).
364
+ edge_step(Current, Relationships, both, Edge, Next) :-
365
+ edge_step(Current, Relationships, outgoing, Edge, Next).
366
+ edge_step(Current, Relationships, both, Edge, Next) :-
367
+ edge_step(Current, Relationships, incoming, Edge, Next).
368
+
369
+ relationship_allowed([], Type) :-
370
+ relationship_type(Type).
371
+ relationship_allowed(Relationships, Type) :-
372
+ memberchk(Type, Relationships).
373
+
374
+ keep_entity_type([], _Id).
375
+ keep_entity_type(EntityTypes, Id) :-
376
+ kb_entity(Id, Type, _),
377
+ memberchk(Type, EntityTypes).
378
+
379
+ edge_kept(KeptNodes, edge(_Type, From, To)) :-
380
+ memberchk(From, KeptNodes),
381
+ memberchk(To, KeptNodes).
382
+
383
+ node_dict(Id, _{id: Id, type: Type, title: Title, status: Status}) :-
384
+ kb_entity(Id, Type, Props),
385
+ entity_title_status_source(Props, Title, Status, _Source).
386
+
387
+ edge_dict(edge(Type, From, To), _{type: Type, from: From, to: To}).
388
+
389
+ matches_type(none, _Type).
390
+ matches_type(TypeFilter, Type) :-
391
+ TypeFilter \= none,
392
+ Type = TypeFilter.
393
+
394
+ matches_tags([], _Props).
395
+ matches_tags(Tags, Props) :-
396
+ memberchk(tags=EntityTags, Props),
397
+ member(Tag, Tags),
398
+ member(Tag, EntityTags),
399
+ !.
400
+
401
+ matches_source(none, _Props).
402
+ matches_source(SourceFilter, Props) :-
403
+ memberchk(source=SourceValue, Props),
404
+ source_value_atom(SourceValue, SourceAtom),
405
+ sub_atom(SourceAtom, _, _, _, SourceFilter).
406
+
407
+ entity_title_status_source(Props, Title, Status, Source) :-
408
+ memberchk(title=TitleValue, Props),
409
+ memberchk(status=StatusValue, Props),
410
+ ( memberchk(source=SourceValue, Props)
411
+ -> source_value_atom(SourceValue, Source)
412
+ ; Source = ''
413
+ ),
414
+ source_value_atom(TitleValue, Title),
415
+ source_value_atom(StatusValue, Status).
416
+
417
+ source_value_atom(Val, Atom) :-
418
+ nonvar(Val),
419
+ compound(Val),
420
+ Val =.. ['^^', Inner, _Type],
421
+ !,
422
+ source_value_atom(Inner, Atom).
423
+ source_value_atom(literal(type(_, Val)), Atom) :-
424
+ !,
425
+ source_value_atom(Val, Atom).
426
+ source_value_atom(Val, Atom) :-
427
+ string(Val),
428
+ !,
429
+ atom_string(Atom, Val).
430
+ source_value_atom(Val, Atom) :-
431
+ atom(Val),
432
+ !,
433
+ Atom = Val.
434
+ source_value_atom(Val, Atom) :-
435
+ term_string(Val, Str),
436
+ atom_string(Atom, Str).
437
+
438
+ sort_dict_rows(Rows, SortedRows) :-
439
+ map_list_to_pairs(dict_row_sort_key, Rows, Pairs),
440
+ keysort(Pairs, SortedPairs),
441
+ pairs_values(SortedPairs, SortedRows).
442
+
443
+ dict_row_sort_key(Row, Key) :-
444
+ Id = Row.get(id),
445
+ Type = Row.get(type),
446
+ format(atom(Key), '~w::~w', [Type, Id]).
447
+
448
+ paginate_rows(Rows, Offset, Limit, PaginatedRows) :-
449
+ length(Prefix, Offset),
450
+ append(Prefix, Rest, Rows),
451
+ length(PaginatedRows, Limit),
452
+ append(PaginatedRows, _Tail, Rest),
453
+ !.
454
+ paginate_rows(Rows, Offset, _Limit, PaginatedRows) :-
455
+ length(Prefix, Offset),
456
+ append(Prefix, PaginatedRows, Rows),
457
+ !.
458
+ paginate_rows(_, _, _, []).
459
+
460
+ dict_json_string(Dict, JsonString) :-
461
+ with_output_to(string(JsonString), json_write_dict(current_output, Dict, [])).
package/src/kb.pl CHANGED
@@ -59,16 +59,15 @@ 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 kb_attached_snapshot/1.
62
63
  :- dynamic entity/4. % Support legacy .pl file format (Type, Id, Title, Props)
63
64
 
64
65
  %% kb_attach(+Directory)
65
66
  % Attach to a KB directory with RDF persistence and file locking.
66
67
  % Creates directory if it doesn't exist.
67
68
  kb_attach(Directory) :-
68
- % If we were already attached in this process, detach first.
69
- % This prevents accidentally loading the same RDF snapshot multiple times.
70
69
  ( kb_attached(_)
71
- -> kb_detach
70
+ -> throw(error(permission_error(attach, kb, Directory), kb_attach/1))
72
71
  ; true
73
72
  ),
74
73
  % Ensure directory exists
@@ -89,6 +88,7 @@ kb_attach(Directory) :-
89
88
  -> rdf_load(DataFile, [graph(GraphURI), silent(true)])
90
89
  ; true
91
90
  ),
91
+ current_data_stamp(DataFile, SnapshotStamp),
92
92
  % Set up audit log - only attach if not already attached
93
93
  atom_concat(Directory, '/audit.log', AuditLog),
94
94
  ( db_attached(AuditLog)
@@ -99,55 +99,114 @@ kb_attach(Directory) :-
99
99
  assert(kb_attached(Directory)),
100
100
  assert(kb_audit_db(AuditLog)),
101
101
  assert(kb_graph(GraphURI)),
102
+ assert(kb_attached_snapshot(SnapshotStamp)),
102
103
 
103
104
  % Load legacy .pl entity files if present
104
105
  load_kb_pl_files(Directory).
105
106
 
106
107
 
107
108
  %% kb_detach
108
- % Safely detach from KB, flushing journals and closing audit log.
109
+ % Safely detach from KB without persisting pending changes.
110
+ % Call kb_save/0 explicitly before kb_detach/0 when durability is required.
111
+ % implements REQ-009
109
112
  kb_detach :-
110
113
  ( kb_attached(_Directory)
111
114
  -> (
112
- kb_save,
113
115
  % Unload RDF graph from memory to prevent duplication on reattach
114
116
  ( kb_graph(GraphURI)
115
117
  -> rdf_unload_graph(GraphURI)
116
118
  ; true
117
119
  ),
118
- % Sync and close audit log
119
- ( kb_audit_db(AuditLog)
120
- -> db_sync(AuditLog)
120
+ ( db_attached(_)
121
+ -> catch(db_detach, _, true)
121
122
  ; true
122
123
  ),
123
124
  % Clear state
124
125
  retractall(kb_attached(_)),
125
126
  retractall(kb_audit_db(_)),
126
- retractall(kb_graph(_))
127
+ retractall(kb_graph(_)),
128
+ retractall(kb_attached_snapshot(_))
127
129
  )
128
130
  ; true
129
131
  ).
130
132
 
131
133
  %% kb_save
132
134
  % Save RDF graph and sync audit log to disk
135
+ % implements REQ-009
133
136
  kb_save :-
134
137
  ( kb_attached(Directory)
135
- -> (
136
- % Save RDF graph to file with namespace declarations
137
- atom_concat(Directory, '/kb.rdf', DataFile),
138
- % Get current graph URI
139
- kb_graph(GraphURI),
140
- % If we have a graph URI, save that graph. Otherwise save all data
141
- % (fallback) so a kb.rdf is always produced. Report errors if save fails.
142
- ( kb_graph(GraphURI)
143
- -> catch(rdf_save(DataFile, [graph(GraphURI), base_uri('urn-kibi:'), namespaces([kb, xsd])]), E, print_message(error, E))
144
- ; catch(rdf_save(DataFile, [base_uri('urn-kibi:'), namespaces([kb, xsd])]), E2, print_message(error, E2))
145
- ),
146
- % Sync audit log
147
- ( kb_audit_db(AuditLog)
148
- -> db_sync(AuditLog)
149
- ; true
150
- )
138
+ -> with_kb_mutex(with_kb_file_lock(Directory, kb_save_locked(Directory)))
139
+ ; true
140
+ ).
141
+
142
+ with_kb_file_lock(Directory, Goal) :-
143
+ atom_concat(Directory, '/kb.lock', LockFile),
144
+ setup_call_cleanup(
145
+ open(LockFile, append, LockStream, [lock(write)]),
146
+ call(Goal),
147
+ close(LockStream)
148
+ ).
149
+
150
+ kb_save_locked(Directory) :-
151
+ atom_concat(Directory, '/kb.rdf', DataFile),
152
+ temp_rdf_file(Directory, TempFile),
153
+ catch(
154
+ (
155
+ ensure_snapshot_current(DataFile),
156
+ save_rdf_snapshot(TempFile),
157
+ rename_file(TempFile, DataFile),
158
+ current_data_stamp(DataFile, UpdatedStamp),
159
+ retractall(kb_attached_snapshot(_)),
160
+ assert(kb_attached_snapshot(UpdatedStamp)),
161
+ sync_audit_log
162
+ ),
163
+ Error,
164
+ (
165
+ cleanup_temp_file(TempFile),
166
+ throw(Error)
167
+ )
168
+ ).
169
+
170
+ temp_rdf_file(Directory, TempFile) :-
171
+ get_time(Timestamp),
172
+ Millis is floor(Timestamp * 1000),
173
+ ( current_prolog_flag(pid, Pid)
174
+ -> true
175
+ ; Pid = 0
176
+ ),
177
+ format(atom(TempFile), '~w/kb.rdf.tmp.~w.~w', [Directory, Pid, Millis]).
178
+
179
+ save_rdf_snapshot(TargetFile) :-
180
+ ( kb_graph(GraphURI)
181
+ -> rdf_save(TargetFile, [graph(GraphURI), base_uri('urn-kibi:'), namespaces([kb, xsd])])
182
+ ; rdf_save(TargetFile, [base_uri('urn-kibi:'), namespaces([kb, xsd])])
183
+ ).
184
+
185
+ sync_audit_log :-
186
+ ( kb_audit_db(AuditLog)
187
+ -> db_sync(AuditLog)
188
+ ; true
189
+ ).
190
+
191
+ cleanup_temp_file(TempFile) :-
192
+ ( exists_file(TempFile)
193
+ -> catch(delete_file(TempFile), _, true)
194
+ ; true
195
+ ).
196
+
197
+ current_data_stamp(DataFile, missing) :-
198
+ \+ exists_file(DataFile),
199
+ !.
200
+ current_data_stamp(DataFile, stamp(MTime, Size)) :-
201
+ time_file(DataFile, MTime),
202
+ size_file(DataFile, Size).
203
+
204
+ ensure_snapshot_current(DataFile) :-
205
+ ( kb_attached_snapshot(ExpectedStamp)
206
+ -> current_data_stamp(DataFile, CurrentStamp),
207
+ ( ExpectedStamp == CurrentStamp
208
+ -> true
209
+ ; throw(error(permission_error(save, kb, stale_snapshot), kb_save/0))
151
210
  )
152
211
  ; true
153
212
  ).
package/src/status.pl ADDED
@@ -0,0 +1,202 @@
1
+ % Module: status
2
+ % Curated KB status and freshness reporting for MCP and CLI surfaces.
3
+
4
+ :- module(status, [
5
+ kb_status_json/1,
6
+ status_meta_dict/1
7
+ ]).
8
+
9
+ :- use_module(library(http/json)).
10
+ :- use_module('kb.pl').
11
+
12
+ kb_status_json(JsonString) :-
13
+ status_meta_dict(StatusDict),
14
+ dict_json_string(StatusDict, JsonString).
15
+
16
+ status_meta_dict(StatusDict) :-
17
+ attached_kb_info(Branch, KbPath, DataFile),
18
+ snapshot_id(SnapshotId),
19
+ synced_at(DataFile, SyncedAt),
20
+ freshness_state(DataFile, Dirty, SyncState),
21
+ StatusDict = _{
22
+ branch: Branch,
23
+ snapshotId: SnapshotId,
24
+ syncedAt: SyncedAt,
25
+ dirty: Dirty,
26
+ syncState: SyncState,
27
+ kbPath: KbPath,
28
+ lastSyncSource: persisted
29
+ }.
30
+
31
+ attached_kb_info(Branch, KbPath, DataFile) :-
32
+ kb:kb_attached(KbPath),
33
+ branch_workspace_from_kb_path(KbPath, Branch, _WorkspaceRoot),
34
+ directory_file_path(KbPath, 'kb.rdf', DataFile).
35
+
36
+ snapshot_id(SnapshotId) :-
37
+ kb:kb_attached_snapshot(stamp(MTime, Size)),
38
+ !,
39
+ format(atom(SnapshotId), 'stamp:~16f:~w', [MTime, Size]).
40
+ snapshot_id(SnapshotId) :-
41
+ kb:kb_attached_snapshot(missing),
42
+ !,
43
+ SnapshotId = missing.
44
+ snapshot_id(unknown).
45
+
46
+ synced_at(DataFile, SyncedAt) :-
47
+ exists_file(DataFile),
48
+ !,
49
+ time_file(DataFile, Timestamp),
50
+ format_time(atom(SyncedAt), '%FT%TZ', Timestamp).
51
+ synced_at(_, @(null)).
52
+
53
+ freshness_state(DataFile, true, stale) :-
54
+ exists_file(DataFile),
55
+ time_file(DataFile, SnapshotTime),
56
+ workspace_state_changed(SnapshotTime),
57
+ !.
58
+ freshness_state(DataFile, false, fresh) :-
59
+ exists_file(DataFile),
60
+ !.
61
+ freshness_state(_, true, unknown).
62
+
63
+ workspace_state_changed(SnapshotTime) :-
64
+ workspace_source_changed(SnapshotTime),
65
+ !.
66
+ workspace_state_changed(SnapshotTime) :-
67
+ documentation_tree_changed(SnapshotTime),
68
+ !.
69
+
70
+ workspace_source_changed(SnapshotTime) :-
71
+ attached_workspace_root(WorkspaceRoot),
72
+ kb_entity(_, _, Props),
73
+ memberchk(source=SourceValue, Props),
74
+ source_value_atom(SourceValue, SourceAtom),
75
+ repo_relative_source(SourceAtom, RelativeSource),
76
+ directory_file_path(WorkspaceRoot, RelativeSource, SourcePath),
77
+ ( exists_file(SourcePath)
78
+ -> time_file(SourcePath, FileTime),
79
+ FileTime > SnapshotTime
80
+ ; true
81
+ ),
82
+ !.
83
+
84
+ documentation_tree_changed(SnapshotTime) :-
85
+ attached_workspace_root(WorkspaceRoot),
86
+ documentation_markdown_untracked(WorkspaceRoot),
87
+ !.
88
+ documentation_tree_changed(SnapshotTime) :-
89
+ attached_workspace_root(WorkspaceRoot),
90
+ directory_file_path(WorkspaceRoot, 'documentation', DocumentationRoot),
91
+ exists_directory(DocumentationRoot),
92
+ directory_tree_newer(DocumentationRoot, SnapshotTime),
93
+ !.
94
+
95
+ documentation_markdown_untracked(WorkspaceRoot) :-
96
+ directory_file_path(WorkspaceRoot, 'documentation', DocumentationRoot),
97
+ exists_directory(DocumentationRoot),
98
+ documentation_markdown_file(DocumentationRoot, FilePath),
99
+ path_relative_to_workspace(WorkspaceRoot, FilePath, RelativePath),
100
+ \+ known_source_path(RelativePath),
101
+ !.
102
+
103
+ documentation_markdown_file(Path, Path) :-
104
+ exists_file(Path),
105
+ file_name_extension(_, md, Path),
106
+ !.
107
+ documentation_markdown_file(Path, FilePath) :-
108
+ exists_directory(Path),
109
+ directory_files(Path, Entries),
110
+ member(Entry, Entries),
111
+ Entry \= '.',
112
+ Entry \= '..',
113
+ directory_file_path(Path, Entry, ChildPath),
114
+ documentation_markdown_file(ChildPath, FilePath).
115
+
116
+ path_relative_to_workspace(WorkspaceRoot, FilePath, RelativePath) :-
117
+ atom_concat(WorkspaceRoot, '/', Prefix),
118
+ atom_concat(Prefix, RelativePath, FilePath).
119
+
120
+ known_source_path(RelativePath) :-
121
+ kb_entity(_, _, Props),
122
+ memberchk(source=SourceValue, Props),
123
+ source_value_atom(SourceValue, SourceAtom),
124
+ repo_relative_source(SourceAtom, RelativePath).
125
+
126
+ directory_tree_newer(Path, SnapshotTime) :-
127
+ time_file(Path, EntryTime),
128
+ EntryTime > SnapshotTime,
129
+ !.
130
+ directory_tree_newer(Path, SnapshotTime) :-
131
+ exists_directory(Path),
132
+ directory_files(Path, Entries),
133
+ member(Entry, Entries),
134
+ Entry \= '.',
135
+ Entry \= '..',
136
+ directory_file_path(Path, Entry, ChildPath),
137
+ directory_tree_newer(ChildPath, SnapshotTime),
138
+ !.
139
+
140
+ attached_workspace_root(WorkspaceRoot) :-
141
+ kb:kb_attached(KbPath),
142
+ branch_workspace_from_kb_path(KbPath, _Branch, WorkspaceRoot).
143
+
144
+ branch_workspace_from_kb_path(KbPath, Branch, WorkspaceRoot) :-
145
+ branch_path_segments(KbPath, BranchesDir, Segments),
146
+ file_directory_name(BranchesDir, KbRoot),
147
+ file_directory_name(KbRoot, WorkspaceRoot),
148
+ atomic_list_concat(Segments, '/', Branch).
149
+
150
+ branch_path_segments(KbPath, BranchesDir, [Base]) :-
151
+ file_directory_name(KbPath, BranchesDir),
152
+ file_base_name(BranchesDir, branches),
153
+ file_base_name(KbPath, Base).
154
+ branch_path_segments(KbPath, BranchesDir, Segments) :-
155
+ file_directory_name(KbPath, Parent),
156
+ Parent \= KbPath,
157
+ branch_path_segments(Parent, BranchesDir, ParentSegments),
158
+ file_base_name(KbPath, Base),
159
+ append(ParentSegments, [Base], Segments).
160
+
161
+ repo_relative_source(SourceAtom, RelativeSource) :-
162
+ strip_fragment(SourceAtom, NoFragment),
163
+ \+ sub_atom(NoFragment, _, _, _, '://'),
164
+ ( attached_workspace_root(WorkspaceRoot),
165
+ workspace_relative_path(WorkspaceRoot, NoFragment, RelativePath)
166
+ -> RelativeSource = RelativePath
167
+ ; RelativeSource = NoFragment
168
+ ).
169
+
170
+ workspace_relative_path(WorkspaceRoot, SourcePath, RelativePath) :-
171
+ atom_concat(WorkspaceRoot, '/', Prefix),
172
+ atom_concat(Prefix, RelativePath, SourcePath).
173
+
174
+ strip_fragment(SourceAtom, NoFragment) :-
175
+ ( sub_atom(SourceAtom, Before, _, _, '#')
176
+ -> sub_atom(SourceAtom, 0, Before, _, NoFragment)
177
+ ; NoFragment = SourceAtom
178
+ ).
179
+
180
+ source_value_atom(Val, Atom) :-
181
+ nonvar(Val),
182
+ compound(Val),
183
+ Val =.. ['^^', Inner, _Type],
184
+ !,
185
+ source_value_atom(Inner, Atom).
186
+ source_value_atom(literal(type(_, Val)), Atom) :-
187
+ !,
188
+ source_value_atom(Val, Atom).
189
+ source_value_atom(Val, Atom) :-
190
+ string(Val),
191
+ !,
192
+ atom_string(Atom, Val).
193
+ source_value_atom(Val, Atom) :-
194
+ atom(Val),
195
+ !,
196
+ Atom = Val.
197
+ source_value_atom(Val, Atom) :-
198
+ term_string(Val, Str),
199
+ atom_string(Atom, Str).
200
+
201
+ dict_json_string(Dict, JsonString) :-
202
+ with_output_to(string(JsonString), json_write_dict(current_output, Dict, [])).