kibi-core 0.1.9 → 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.9",
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",
package/src/checks.pl CHANGED
@@ -324,7 +324,7 @@ deprecated_adr_violation(violation(
324
324
  )) :-
325
325
  deprecated_no_successor(AdrId),
326
326
 
327
- Description = "Archived/deprecated ADR has no successor — add a supersedes link from the replacement ADR",
327
+ Description = "Superseded/deprecated ADR has no successor — add a supersedes link from the replacement ADR",
328
328
 
329
329
  format(string(Suggestion), "Create a new ADR and add: links: [{type: supersedes, target: ~w}]", [AdrId]),
330
330
 
@@ -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
  ).
@@ -554,12 +613,12 @@ conflicting(Adr1, Adr2) :-
554
613
  Adr1 @< Adr2.
555
614
 
556
615
  %% deprecated_still_used(+Adr, -Symbols)
557
- % Deprecated/archived/rejected ADRs that still constrain symbols.
616
+ % Deprecated/superseded ADRs that still constrain symbols.
558
617
  deprecated_still_used(Adr, Symbols) :-
559
618
  kb_entity(Adr, adr, Props),
560
619
  memberchk(status=Status, Props),
561
620
  normalize_term_atom(Status, StatusAtom),
562
- memberchk(StatusAtom, [deprecated, archived, rejected]),
621
+ memberchk(StatusAtom, [deprecated, superseded]),
563
622
  setof(Symbol, kb_relationship(constrained_by, Symbol, Adr), Symbols),
564
623
  !.
565
624
  deprecated_still_used(_, []).
@@ -569,9 +628,11 @@ deprecated_still_used(_, []).
569
628
  %% ------------------------------------------------------------------
570
629
 
571
630
  %% current_adr(+Id)
572
- % True when Id is an ADR not superseded by any other ADR.
631
+ % True when Id is an accepted ADR not superseded by any other ADR.
573
632
  current_adr(Id) :-
574
- kb_entity(Id, adr, _),
633
+ kb_entity(Id, adr, Props),
634
+ memberchk(status=Status, Props),
635
+ normalize_term_atom(Status, accepted),
575
636
  \+ kb_relationship(supersedes, _, Id).
576
637
 
577
638
  %% superseded_by(+OldId, -NewId)
@@ -593,20 +654,23 @@ adr_chain_acc(Id, Visited, [Id|Rest]) :-
593
654
  adr_chain_acc(Newer, [Id|Visited], Rest).
594
655
 
595
656
  %% deprecated_no_successor(+OldId)
596
- % Lint rule: ADR is archived/deprecated but has no supersedes relationship pointing to it.
657
+ % Lint rule: ADR is superseded/deprecated but has no supersedes relationship pointing to it.
597
658
  deprecated_no_successor(Id) :-
598
659
  kb_entity(Id, adr, Props),
599
660
  memberchk(status=Status, Props),
600
661
  normalize_term_atom(Status, StatusAtom),
601
- memberchk(StatusAtom, [archived, deprecated]),
662
+ memberchk(StatusAtom, [superseded, deprecated]),
602
663
  \+ kb_relationship(supersedes, _, Id).
603
664
 
604
665
  %% current_req(+Id)
605
- % Requirement is current when active and not superseded by another requirement.
666
+ % Requirement is current when not deprecated and not superseded by another requirement.
667
+ % Canonical statuses: open, in_progress, closed.
668
+ % Legacy statuses accepted for backwards compatibility: active, approved.
606
669
  current_req(Id) :-
607
670
  kb_entity(Id, req, Props),
608
671
  memberchk(status=Status, Props),
609
- normalize_term_atom(Status, active),
672
+ normalize_term_atom(Status, StatusAtom),
673
+ memberchk(StatusAtom, [open, in_progress, closed, active, approved]),
610
674
  \+ kb_relationship(supersedes, _, Id).
611
675
 
612
676
  %% contradicting_reqs(-ReqA, -ReqB, -Reason)
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, [])).