python-delphi-lsp 3.6.0__cp310-abi3-win_amd64.whl

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 (56) hide show
  1. delphi_lsp/__init__.py +184 -0
  2. delphi_lsp/_version.py +1 -0
  3. delphi_lsp/agent_cache.py +1574 -0
  4. delphi_lsp/agent_cli.py +902 -0
  5. delphi_lsp/agent_context.py +3907 -0
  6. delphi_lsp/agent_cpg.py +349 -0
  7. delphi_lsp/agent_cpg_builder.py +666 -0
  8. delphi_lsp/agent_layers.py +1410 -0
  9. delphi_lsp/agent_metrics.py +526 -0
  10. delphi_lsp/agent_protocol.py +445 -0
  11. delphi_lsp/agent_relations.py +1881 -0
  12. delphi_lsp/agent_templates.py +614 -0
  13. delphi_lsp/agent_wiki.py +2853 -0
  14. delphi_lsp/agent_workspace.py +860 -0
  15. delphi_lsp/ast_serialize.py +126 -0
  16. delphi_lsp/binary.py +250 -0
  17. delphi_lsp/comment_builder.py +28 -0
  18. delphi_lsp/consts.py +345 -0
  19. delphi_lsp/delphi_lsp_native.pyd +0 -0
  20. delphi_lsp/delphiast_lexer.py +399 -0
  21. delphi_lsp/delphiast_parser.py +2189 -0
  22. delphi_lsp/delphiast_tokens.py +41 -0
  23. delphi_lsp/grammar.py +459 -0
  24. delphi_lsp/incremental.py +1139 -0
  25. delphi_lsp/lark_builder.py +2684 -0
  26. delphi_lsp/lark_tokens.py +236 -0
  27. delphi_lsp/lexical_scanner.py +30 -0
  28. delphi_lsp/lsp_server.py +6698 -0
  29. delphi_lsp/metrics.py +1452 -0
  30. delphi_lsp/native_bridge.py +300 -0
  31. delphi_lsp/navigation_cache.py +189 -0
  32. delphi_lsp/nodes.py +462 -0
  33. delphi_lsp/parallel_outline.py +662 -0
  34. delphi_lsp/parse_artifacts.py +969 -0
  35. delphi_lsp/parser.py +342 -0
  36. delphi_lsp/parser_backend.py +139 -0
  37. delphi_lsp/preprocessor.py +1122 -0
  38. delphi_lsp/progress.py +26 -0
  39. delphi_lsp/project_config.py +285 -0
  40. delphi_lsp/project_discovery.py +932 -0
  41. delphi_lsp/project_indexer.py +470 -0
  42. delphi_lsp/py.typed +1 -0
  43. delphi_lsp/response_cache.py +465 -0
  44. delphi_lsp/semantic.py +394 -0
  45. delphi_lsp/semantic_builder.py +2302 -0
  46. delphi_lsp/source_reader.py +17 -0
  47. delphi_lsp/wiki_external_sort.py +392 -0
  48. delphi_lsp/wiki_memory.py +548 -0
  49. delphi_lsp/workspace.py +83 -0
  50. delphi_lsp/writer.py +73 -0
  51. python_delphi_lsp-3.6.0.dist-info/METADATA +766 -0
  52. python_delphi_lsp-3.6.0.dist-info/RECORD +56 -0
  53. python_delphi_lsp-3.6.0.dist-info/WHEEL +4 -0
  54. python_delphi_lsp-3.6.0.dist-info/entry_points.txt +3 -0
  55. python_delphi_lsp-3.6.0.dist-info/licenses/LICENSE +373 -0
  56. python_delphi_lsp-3.6.0.dist-info/sboms/python-delphi-lsp.cyclonedx.json +770 -0
@@ -0,0 +1,2853 @@
1
+ from __future__ import annotations
2
+
3
+ import gc
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import sqlite3
10
+ import sys
11
+ import tempfile
12
+ import unicodedata
13
+ from collections import OrderedDict
14
+ from collections.abc import Callable, Iterable, Iterator, Mapping
15
+ from contextlib import contextmanager
16
+ from dataclasses import asdict, dataclass
17
+ from functools import lru_cache
18
+ from itertools import chain
19
+ from pathlib import Path, PurePosixPath
20
+ from typing import Any
21
+
22
+ from ._version import __version__
23
+ from .agent_layers import (
24
+ CodebaseIndex,
25
+ _all_symbols,
26
+ _source_fragment,
27
+ build_codebase_index,
28
+ layer_payload,
29
+ )
30
+ from .agent_metrics import build_path_metrics
31
+ from .agent_protocol import (
32
+ SCHEMA_VERSION,
33
+ SUPPORTED_ACTIONS,
34
+ SUPPORTED_DETAILS,
35
+ SUPPORTED_DIRECTIONS,
36
+ SUPPORTED_GRAPHS,
37
+ SUPPORTED_RELATIONS,
38
+ )
39
+ from .parallel_outline import OutlineResult, ParallelBuildStats
40
+ from .parse_artifacts import semantic_model_from_mapping, semantic_model_to_mapping
41
+ from .parser import DelphiParser
42
+ from .parser_backend import ParserMode
43
+ from .project_config import workspace_include_loader
44
+ from .project_discovery import DelphiProjectDiscovery
45
+ from .project_indexer import ProjectIndexResult
46
+ from .semantic import Scope, Symbol, SymbolIndex, SymbolReference, normalize_name
47
+ from .semantic_builder import SemanticModel
48
+ from .source_reader import read_source_text
49
+ from .wiki_external_sort import sort_jsonl_records
50
+ from .wiki_memory import (
51
+ MemoryStage,
52
+ MemoryTransition,
53
+ WikiMemoryGuard,
54
+ WikiMemoryLimitExceeded,
55
+ resolve_memory_limit_bytes,
56
+ )
57
+
58
+ OKF_VERSION = "0.2"
59
+ _WIKI_ARTIFACT_RECURSION_LIMIT = 50_000
60
+ _STREAMING_UNIT_GC_INTERVAL = 16
61
+
62
+
63
+ @contextmanager
64
+ def _expanded_recursion_limit() -> Iterator[None]:
65
+ previous = sys.getrecursionlimit()
66
+ if previous < _WIKI_ARTIFACT_RECURSION_LIMIT:
67
+ sys.setrecursionlimit(_WIKI_ARTIFACT_RECURSION_LIMIT)
68
+ try:
69
+ yield
70
+ finally:
71
+ if previous < _WIKI_ARTIFACT_RECURSION_LIMIT:
72
+ sys.setrecursionlimit(previous)
73
+
74
+
75
+ class WikiExportError(RuntimeError):
76
+ """Raised when an OKF bundle cannot be created safely."""
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class WikiExportResult:
81
+ output: str
82
+ documents: int
83
+ projects: int
84
+ units: int
85
+ symbols: int
86
+ peak_process_tree_rss_bytes: int
87
+ phase_peak_process_tree_rss_bytes: dict[str, int]
88
+
89
+ def to_mapping(self) -> dict[str, Any]:
90
+ return {
91
+ "format": "okf",
92
+ "okf_version": OKF_VERSION,
93
+ **asdict(self),
94
+ }
95
+
96
+
97
+ @dataclass(frozen=True, slots=True)
98
+ class WikiProgressEvent:
99
+ phase: str
100
+ completed: int
101
+ total: int | None
102
+ path: str
103
+ detail: str
104
+
105
+
106
+ WikiProgressCallback = Callable[[WikiProgressEvent], None]
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class _UnitRecord:
111
+ source_path: str
112
+ name: str
113
+ page: PurePosixPath
114
+
115
+
116
+ def _iter_symbols(
117
+ scope: Scope,
118
+ *,
119
+ include_unit: bool = True,
120
+ seen: set[int] | None = None,
121
+ ) -> Iterator[Symbol]:
122
+ """Depth-first symbol traversal without Python call-stack growth."""
123
+ visited = set() if seen is None else seen
124
+ pending: list[Iterator[Symbol]] = []
125
+
126
+ def enter(candidate: Scope) -> bool:
127
+ scope_id = id(candidate)
128
+ if scope_id in visited:
129
+ return False
130
+ visited.add(scope_id)
131
+ pending.append(
132
+ iter(
133
+ symbol
134
+ for symbols in candidate.symbols.values()
135
+ for symbol in symbols
136
+ )
137
+ )
138
+ return True
139
+
140
+ enter(scope)
141
+ while pending:
142
+ try:
143
+ symbol = next(pending[-1])
144
+ except StopIteration:
145
+ pending.pop()
146
+ continue
147
+ if include_unit or symbol.kind.value != "unit":
148
+ yield symbol
149
+ if symbol.member_scope is not None:
150
+ enter(symbol.member_scope)
151
+
152
+
153
+ @dataclass(frozen=True, slots=True)
154
+ class _StoredModelRecord:
155
+ source_path: str
156
+ name: str
157
+ artifact_path: Path
158
+ symbol_count: int
159
+
160
+
161
+ @dataclass(slots=True)
162
+ class _WikiExecutionStrategy:
163
+ workers: int
164
+ outline_worker_limit: int = 2
165
+ sort_records_per_run: int = 50_000
166
+ sort_bytes_per_run: int = 8 * 1024 * 1024
167
+
168
+ def reduce_workers(self) -> None:
169
+ effective = self.workers if self.workers > 0 else min(8, os.cpu_count() or 1)
170
+ self.workers = max(1, effective // 2)
171
+ self.outline_worker_limit = max(1, self.outline_worker_limit // 2)
172
+
173
+ def sequentialize(self) -> None:
174
+ self.workers = 1
175
+ self.outline_worker_limit = 1
176
+ self.sort_records_per_run = min(self.sort_records_per_run, 5_000)
177
+ self.sort_bytes_per_run = min(self.sort_bytes_per_run, 8 * 1024 * 1024)
178
+
179
+
180
+ class _StoredWikiModels(Mapping[str, SemanticModel]):
181
+ """Disk-backed outline models with a small, explicitly clearable LRU."""
182
+
183
+ def __init__(
184
+ self,
185
+ workspace: Path,
186
+ *,
187
+ max_cached_models: int = 128,
188
+ max_cached_model_bytes: int = 0,
189
+ ) -> None:
190
+ if max_cached_models < 0:
191
+ raise ValueError("max_cached_models must not be negative")
192
+ if max_cached_model_bytes < 0:
193
+ raise ValueError("max_cached_model_bytes must not be negative")
194
+ self.workspace = workspace
195
+ self.model_directory = workspace / "models"
196
+ self.model_directory.mkdir(parents=True, exist_ok=True)
197
+ self.catalog_path = workspace / "symbols-raw.jsonl"
198
+ self._records: dict[str, _StoredModelRecord] = {}
199
+ self._ordered_sources: list[str] = []
200
+ self._source_by_unit: dict[str, str] = {}
201
+ self._max_cached_models = max_cached_models
202
+ self._max_cached_model_bytes = max_cached_model_bytes
203
+ self._cache: OrderedDict[str, SemanticModel] = OrderedDict()
204
+ self._cache_weights: dict[str, int] = {}
205
+ self._cached_model_bytes = 0
206
+
207
+ def store(self, result: OutlineResult) -> None:
208
+ if result.read_error or result.model is None:
209
+ return
210
+ digest = hashlib.sha256(result.source_path.encode("utf-8")).hexdigest()
211
+ artifact_path = self.model_directory / f"{digest}.json"
212
+ with (
213
+ _expanded_recursion_limit(),
214
+ artifact_path.open("w", encoding="utf-8", newline="\n") as stream,
215
+ ):
216
+ json.dump(
217
+ semantic_model_to_mapping(result.model),
218
+ stream,
219
+ ensure_ascii=False,
220
+ separators=(",", ":"),
221
+ )
222
+ stream.write("\n")
223
+ symbol_count = 0
224
+ with self.catalog_path.open("a", encoding="utf-8", newline="\n") as catalog:
225
+ for symbol in _iter_symbols(result.model.unit_scope, include_unit=False):
226
+ symbol_count += 1
227
+ identity = _symbol_identity(symbol)
228
+ catalog.write(
229
+ json.dumps(
230
+ {
231
+ "identity": identity,
232
+ "kind": symbol.kind.value,
233
+ "model_source": result.source_path,
234
+ "name": symbol.name,
235
+ "name_key": symbol.name.casefold(),
236
+ "page": _symbol_page(symbol).as_posix(),
237
+ "source_key": _normalized_source(symbol.decl_range.file_name),
238
+ "start_col": symbol.decl_range.start_col,
239
+ "start_line": symbol.decl_range.start_line,
240
+ },
241
+ ensure_ascii=False,
242
+ separators=(",", ":"),
243
+ )
244
+ )
245
+ catalog.write("\n")
246
+ self._records[result.source_path] = _StoredModelRecord(
247
+ result.source_path,
248
+ result.model.unit_scope.name,
249
+ artifact_path,
250
+ symbol_count,
251
+ )
252
+
253
+ def finalize(self, source_order: Iterable[str]) -> None:
254
+ self._ordered_sources = [source for source in source_order if source in self._records]
255
+ self._source_by_unit = {}
256
+ for source in self._ordered_sources:
257
+ self._source_by_unit[self._records[source].name.casefold()] = source
258
+ self.catalog_path.touch(exist_ok=True)
259
+
260
+ def source_for_unit(self, name: str) -> str | None:
261
+ return self._source_by_unit.get(name.casefold())
262
+
263
+ def unit_records(self) -> list[_UnitRecord]:
264
+ records = [
265
+ _UnitRecord(
266
+ source,
267
+ self._records[source].name,
268
+ PurePosixPath("units")
269
+ / _document_name(
270
+ self._records[source].name,
271
+ _normalized_source(source),
272
+ ),
273
+ )
274
+ for source in self._ordered_sources
275
+ ]
276
+ return sorted(
277
+ records,
278
+ key=lambda record: (record.name.casefold(), record.source_path.casefold()),
279
+ )
280
+
281
+ def __getitem__(self, source_path: str) -> SemanticModel:
282
+ cached = self._cache.get(source_path)
283
+ if cached is not None:
284
+ self._cache.move_to_end(source_path)
285
+ cached.index = SymbolIndex()
286
+ return cached
287
+ record = self._records[source_path]
288
+ with _expanded_recursion_limit():
289
+ with record.artifact_path.open("r", encoding="utf-8") as stream:
290
+ payload = json.load(stream)
291
+ if not isinstance(payload, dict):
292
+ raise WikiExportError(f"Invalid stored outline model: {source_path}")
293
+ model = semantic_model_from_mapping(payload)
294
+ model.index = SymbolIndex()
295
+ weight = record.artifact_path.stat().st_size
296
+ if (
297
+ self._max_cached_models
298
+ and weight <= self._max_cached_model_bytes
299
+ ):
300
+ self._cache[source_path] = model
301
+ self._cache_weights[source_path] = weight
302
+ self._cached_model_bytes += weight
303
+ self._cache.move_to_end(source_path)
304
+ while (
305
+ len(self._cache) > self._max_cached_models
306
+ or self._cached_model_bytes > self._max_cached_model_bytes
307
+ ):
308
+ expired, _model = self._cache.popitem(last=False)
309
+ self._cached_model_bytes -= self._cache_weights.pop(expired)
310
+ return model
311
+
312
+ def clear_cache(self) -> None:
313
+ self._cache.clear()
314
+ self._cache_weights.clear()
315
+ self._cached_model_bytes = 0
316
+
317
+ def __iter__(self) -> Iterator[str]:
318
+ return iter(self._ordered_sources)
319
+
320
+ def __len__(self) -> int:
321
+ return len(self._ordered_sources)
322
+
323
+
324
+ class _LazyWikiSymbolIndex(SymbolIndex):
325
+ """Load only units actually imported by the unit currently being parsed."""
326
+
327
+ def __init__(self, models: _StoredWikiModels) -> None:
328
+ super().__init__()
329
+ self.models = models
330
+
331
+ def register_unit(
332
+ self,
333
+ unit_name: str,
334
+ unit_scope: Scope,
335
+ *,
336
+ index_symbols: bool = True,
337
+ ) -> None:
338
+ del index_symbols
339
+ super().register_unit(unit_name, unit_scope, index_symbols=False)
340
+
341
+ def lookup_unit(self, unit_name: str): # type: ignore[no-untyped-def]
342
+ normalized = normalize_name(unit_name)
343
+ existing = self.units.get(normalized)
344
+ if existing is not None:
345
+ return existing
346
+ source = self.models.source_for_unit(unit_name)
347
+ if source is None:
348
+ return None
349
+ model = self.models[source]
350
+ self.register_unit(model.unit_scope.name, model.unit_scope)
351
+ return self.units.get(normalized)
352
+
353
+
354
+ class _WikiProjectStore:
355
+ def __init__(self, path: Path) -> None:
356
+ self.path = path
357
+ self.count = 0
358
+
359
+ def append(self, project: str, result: ProjectIndexResult) -> None:
360
+ item = _project_item(project, result)
361
+ with self.path.open("a", encoding="utf-8", newline="\n") as stream:
362
+ stream.write(json.dumps(item, ensure_ascii=False, separators=(",", ":")))
363
+ stream.write("\n")
364
+ self.count += 1
365
+
366
+ def items(self) -> Iterator[dict[str, Any]]:
367
+ if not self.path.is_file():
368
+ return
369
+ with self.path.open("r", encoding="utf-8") as stream:
370
+ for line in stream:
371
+ item = json.loads(line)
372
+ if isinstance(item, dict):
373
+ yield item
374
+
375
+
376
+ class _ProjectMembershipStore:
377
+ def __init__(self, path: Path) -> None:
378
+ self.connection = sqlite3.connect(path)
379
+ self.connection.execute(
380
+ "CREATE TABLE membership (source TEXT, ordinal INTEGER, title TEXT, page TEXT, "
381
+ "PRIMARY KEY (source, title, page))"
382
+ )
383
+
384
+ def add(
385
+ self,
386
+ source: str,
387
+ ordinal: int,
388
+ title: str,
389
+ page: PurePosixPath,
390
+ ) -> None:
391
+ self.connection.execute(
392
+ "INSERT OR IGNORE INTO membership VALUES (?, ?, ?, ?)",
393
+ (_normalized_source(source), ordinal, title, page.as_posix()),
394
+ )
395
+
396
+ def for_source(self, source: str) -> list[tuple[str, PurePosixPath]]:
397
+ rows = self.connection.execute(
398
+ "SELECT title, page FROM membership WHERE source = ? ORDER BY ordinal",
399
+ (_normalized_source(source),),
400
+ )
401
+ return [(str(title), PurePosixPath(str(page))) for title, page in rows]
402
+
403
+ def commit(self) -> None:
404
+ self.connection.commit()
405
+
406
+ def close(self) -> None:
407
+ self.connection.close()
408
+
409
+
410
+ class _BoundedSourceCache(OrderedDict[str, list[str]]):
411
+ """Keep only a few decoded source files resident during page generation."""
412
+
413
+ def __init__(self, max_entries: int = 4) -> None:
414
+ super().__init__()
415
+ self.max_entries = max_entries
416
+
417
+ def get(self, key: str, default: list[str] | None = None) -> list[str] | None:
418
+ value = super().get(key, default)
419
+ if key in self:
420
+ self.move_to_end(key)
421
+ return value
422
+
423
+ def __setitem__(self, key: str, value: list[str]) -> None:
424
+ super().__setitem__(key, value)
425
+ self.move_to_end(key)
426
+ while len(self) > self.max_entries:
427
+ self.popitem(last=False)
428
+
429
+
430
+ def export_okf_wiki(
431
+ root: str | Path,
432
+ output: str | Path,
433
+ *,
434
+ project_file: str | Path | None = None,
435
+ workers: int = 0,
436
+ max_memory_bytes: int | None = None,
437
+ force: bool = False,
438
+ on_progress: WikiProgressCallback | None = None,
439
+ ) -> WikiExportResult:
440
+ """Export all unique layered codebase knowledge as an OKF 0.2 bundle."""
441
+
442
+ root_path = Path(root).expanduser().resolve()
443
+ requested_output, output_path = _resolve_output_destination(output)
444
+ _validate_paths(root_path, output_path, force=force)
445
+
446
+ def forward_index_progress(event: Any) -> None:
447
+ if event.phase in {"inventory", "detail"} or event.detail in {
448
+ "project index started",
449
+ "project index complete",
450
+ }:
451
+ guard.check(phase="project indexing")
452
+ if event.phase in {"inventory", "detail"}:
453
+ return
454
+ if event.detail in {"project index started", "project index complete"}:
455
+ return
456
+ phase = {
457
+ "outline": "index",
458
+ "relations": "relations",
459
+ "projects": "projects",
460
+ "complete": "index",
461
+ }.get(event.phase, "discovery")
462
+ _emit_wiki_progress(
463
+ on_progress,
464
+ phase,
465
+ event.files_completed,
466
+ event.files_total,
467
+ event.path,
468
+ event.detail,
469
+ )
470
+
471
+ output_path.parent.mkdir(parents=True, exist_ok=True)
472
+ temporary = Path(
473
+ tempfile.mkdtemp(prefix=f".{output_path.name}.tmp-", dir=output_path.parent)
474
+ )
475
+ workspace = Path(
476
+ tempfile.mkdtemp(prefix=f".{output_path.name}.work-", dir=output_path.parent)
477
+ )
478
+ strategy = _WikiExecutionStrategy(workers)
479
+
480
+ def memory_transition(transition: MemoryTransition) -> None:
481
+ _emit_wiki_progress(
482
+ on_progress,
483
+ "memory",
484
+ (MemoryStage.COMPACT, MemoryStage.REDUCE_WORKERS, MemoryStage.SEQUENTIAL).index(
485
+ transition.stage
486
+ )
487
+ + 1,
488
+ 3,
489
+ transition.phase,
490
+ (
491
+ f"memory guard {transition.stage.value}: "
492
+ f"{transition.used_bytes} of {transition.limit_bytes} bytes"
493
+ ),
494
+ )
495
+
496
+ models = _StoredWikiModels(workspace)
497
+ writer: _StreamingWikiWriter | None = None
498
+
499
+ def compact_memory() -> None:
500
+ models.clear_cache()
501
+ if writer is not None:
502
+ writer.clear_caches()
503
+ gc.collect()
504
+
505
+ try:
506
+ memory_limit = resolve_memory_limit_bytes(max_memory_bytes)
507
+ except ValueError as error:
508
+ shutil.rmtree(temporary, ignore_errors=True)
509
+ shutil.rmtree(workspace, ignore_errors=True)
510
+ raise WikiExportError(str(error)) from error
511
+ guard = WikiMemoryGuard(
512
+ limit_bytes=memory_limit,
513
+ compact=compact_memory,
514
+ reduce_workers=strategy.reduce_workers,
515
+ sequentialize=strategy.sequentialize,
516
+ on_transition=memory_transition,
517
+ )
518
+ projects = _WikiProjectStore(workspace / "projects.jsonl")
519
+
520
+ def store_outline(result: OutlineResult) -> None:
521
+ models.store(result)
522
+ guard.check(phase="unit outlines")
523
+
524
+ def store_project(project: str, result: ProjectIndexResult) -> None:
525
+ projects.append(project, result)
526
+ guard.check(phase="project indexing")
527
+
528
+ try:
529
+ _emit_wiki_progress(
530
+ on_progress,
531
+ "discovery",
532
+ 0,
533
+ None,
534
+ str(root_path),
535
+ "discovering repository sources",
536
+ )
537
+ index = _build_wiki_index(
538
+ root_path,
539
+ project_file=project_file,
540
+ workers=strategy.workers,
541
+ memory_limit_bytes=memory_limit,
542
+ on_project_result=store_project,
543
+ on_outline_result=store_outline,
544
+ outline_heartbeat=lambda: guard.check(
545
+ phase="unit outlines",
546
+ ),
547
+ outline_force_heartbeat=lambda: guard.check(
548
+ phase="unit outlines",
549
+ force_process_tree=True,
550
+ ),
551
+ outline_active_worker_limit=lambda: strategy.outline_worker_limit,
552
+ on_progress=forward_index_progress,
553
+ )
554
+ guard.check(phase="project indexing", force_process_tree=True)
555
+ models.finalize(index.discovery.source_files)
556
+ guard.check(phase="unit outlines", force_process_tree=True)
557
+ index.models = models
558
+ unique_catalog, name_catalog, symbol_count = _prepare_symbol_catalog(
559
+ models,
560
+ workspace,
561
+ strategy,
562
+ guard,
563
+ )
564
+ guard.check(phase="external symbol sort", force_process_tree=True)
565
+ writer = _StreamingWikiWriter(
566
+ index,
567
+ temporary,
568
+ models=models,
569
+ projects=projects,
570
+ unique_catalog=unique_catalog,
571
+ name_catalog=name_catalog,
572
+ symbol_count=symbol_count,
573
+ workspace=workspace,
574
+ guard=guard,
575
+ strategy=strategy,
576
+ on_progress=on_progress,
577
+ )
578
+ writer.write()
579
+ documents = writer.documents
580
+ symbol_count = writer.symbol_count
581
+ guard.check(phase="finalization", force_process_tree=True)
582
+ guard.check(phase="finalization", force_process_tree=True)
583
+ _emit_wiki_progress(
584
+ on_progress,
585
+ "install",
586
+ 0,
587
+ 1,
588
+ str(output_path),
589
+ "installing completed bundle",
590
+ )
591
+ _install_completed_bundle(
592
+ temporary,
593
+ output_path,
594
+ requested_output=requested_output,
595
+ force=force,
596
+ )
597
+ except WikiMemoryLimitExceeded as error:
598
+ if temporary.exists():
599
+ shutil.rmtree(temporary, ignore_errors=True)
600
+ raise WikiExportError(
601
+ f"{error} Retry with a larger --max-memory value or fewer workers."
602
+ ) from error
603
+ except BaseException:
604
+ if temporary.exists():
605
+ shutil.rmtree(temporary, ignore_errors=True)
606
+ raise
607
+ finally:
608
+ if writer is not None:
609
+ writer.close()
610
+ shutil.rmtree(workspace, ignore_errors=True)
611
+
612
+ result = WikiExportResult(
613
+ output=str(output_path),
614
+ documents=documents,
615
+ projects=len(index.discovery.project_files),
616
+ units=len(index.models),
617
+ symbols=symbol_count,
618
+ peak_process_tree_rss_bytes=guard.peak_bytes,
619
+ phase_peak_process_tree_rss_bytes=dict(sorted(guard.phase_peaks.items())),
620
+ )
621
+ _emit_wiki_progress(
622
+ on_progress,
623
+ "complete",
624
+ 1,
625
+ 1,
626
+ str(output_path),
627
+ "wiki export complete",
628
+ )
629
+ return result
630
+
631
+
632
+ def _build_wiki_index(
633
+ root: Path,
634
+ *,
635
+ project_file: str | Path | None,
636
+ workers: int,
637
+ memory_limit_bytes: int,
638
+ on_project_result: Callable[[str, ProjectIndexResult], None],
639
+ on_outline_result: Callable[[OutlineResult], None],
640
+ outline_heartbeat: Callable[[], object],
641
+ outline_force_heartbeat: Callable[[], object],
642
+ outline_active_worker_limit: Callable[[], int],
643
+ on_progress: Callable[[Any], None],
644
+ ) -> CodebaseIndex:
645
+ requested_outline_workers = (
646
+ workers
647
+ if workers > 0
648
+ else max(1, min(2, (os.cpu_count() or 1) - 1))
649
+ )
650
+ outline_workers = max(1, min(2, requested_outline_workers))
651
+ source_roots = (
652
+ _manifest_source_roots(root)
653
+ if project_file is None
654
+ else (root,)
655
+ )
656
+ merge_workspace_sources = len(source_roots) > 1
657
+ indexes = [
658
+ build_codebase_index(
659
+ source_root,
660
+ project_file=project_file if source_root == root else None,
661
+ index_projects=True,
662
+ main_projects_only=True,
663
+ scan_workspace_sources=(
664
+ True
665
+ if merge_workspace_sources
666
+ else None
667
+ ),
668
+ retain_project_syntax=False,
669
+ retain_project_results=False,
670
+ retain_outline_models=False,
671
+ project_source_roots=(source_root,),
672
+ on_progress=on_progress,
673
+ on_project_result=on_project_result,
674
+ on_outline_result=on_outline_result,
675
+ outline_heartbeat=outline_heartbeat,
676
+ outline_force_heartbeat=outline_force_heartbeat,
677
+ outline_active_worker_limit=outline_active_worker_limit,
678
+ # Keep the Windows spawn multiplier bounded while recovering
679
+ # enough parse throughput for multi-million-line corpora.
680
+ workers=outline_workers,
681
+ project_workers=1,
682
+ outline_memory_budget_bytes=memory_limit_bytes * 8 // 10,
683
+ use_artifact_cache=False,
684
+ )
685
+ for source_root in source_roots
686
+ ]
687
+ if len(indexes) == 1:
688
+ return indexes[0]
689
+ return _merge_wiki_indexes(root, indexes)
690
+
691
+
692
+ def _prepare_symbol_catalog(
693
+ models: _StoredWikiModels,
694
+ workspace: Path,
695
+ strategy: _WikiExecutionStrategy,
696
+ guard: WikiMemoryGuard,
697
+ ) -> tuple[Path, Path, int]:
698
+ source_order = {source: ordinal for ordinal, source in enumerate(models)}
699
+ sorted_catalog = workspace / "symbols-by-identity.jsonl"
700
+ unique_catalog = workspace / "symbols-unique.jsonl"
701
+ name_catalog = workspace / "symbols-by-name.jsonl"
702
+ sort_jsonl_records(
703
+ models.catalog_path,
704
+ sorted_catalog,
705
+ key=lambda item: (
706
+ str(item["identity"]),
707
+ f"{source_order.get(str(item['model_source']), len(source_order)):012d}",
708
+ ),
709
+ max_records_per_run=strategy.sort_records_per_run,
710
+ max_bytes_per_run=strategy.sort_bytes_per_run,
711
+ temp_dir=workspace,
712
+ heartbeat=lambda: guard.check(
713
+ phase="external symbol sort",
714
+ ),
715
+ force_heartbeat=lambda: guard.check(
716
+ phase="external symbol sort",
717
+ force_process_tree=True,
718
+ ),
719
+ )
720
+ symbol_count = 0
721
+ previous_identity: str | None = None
722
+ with (
723
+ sorted_catalog.open("r", encoding="utf-8") as source,
724
+ unique_catalog.open("w", encoding="utf-8", newline="\n") as target,
725
+ ):
726
+ for line in source:
727
+ item = json.loads(line)
728
+ identity = str(item["identity"])
729
+ if identity == previous_identity:
730
+ continue
731
+ previous_identity = identity
732
+ target.write(line)
733
+ symbol_count += 1
734
+ sort_jsonl_records(
735
+ unique_catalog,
736
+ name_catalog,
737
+ key=lambda item: (
738
+ str(item["name_key"]),
739
+ str(item["source_key"]),
740
+ f"{int(item['start_line']):012d}",
741
+ f"{int(item['start_col']):012d}",
742
+ str(item["kind"]),
743
+ ),
744
+ max_records_per_run=strategy.sort_records_per_run,
745
+ max_bytes_per_run=strategy.sort_bytes_per_run,
746
+ temp_dir=workspace,
747
+ heartbeat=lambda: guard.check(
748
+ phase="external symbol sort",
749
+ ),
750
+ force_heartbeat=lambda: guard.check(
751
+ phase="external symbol sort",
752
+ force_process_tree=True,
753
+ ),
754
+ )
755
+ return unique_catalog, name_catalog, symbol_count
756
+
757
+
758
+ def _manifest_source_roots(root: Path) -> tuple[Path, ...]:
759
+ manifest = root / "corpus-manifest.json"
760
+ try:
761
+ payload = json.loads(manifest.read_text(encoding="utf-8"))
762
+ except (OSError, UnicodeError, ValueError):
763
+ return (root,)
764
+ corpora = payload.get("corpora") if isinstance(payload, dict) else None
765
+ if not isinstance(corpora, list):
766
+ return (root,)
767
+ roots: dict[str, Path] = {}
768
+ for corpus in corpora:
769
+ name = corpus.get("name") if isinstance(corpus, dict) else None
770
+ if not isinstance(name, str) or not name.strip():
771
+ return (root,)
772
+ candidate = (root / name).resolve()
773
+ if not candidate.is_relative_to(root) or not candidate.is_dir():
774
+ return (root,)
775
+ roots.setdefault(str(candidate).casefold(), candidate)
776
+ if len(roots) < 2:
777
+ return (root,)
778
+ return tuple(sorted(roots.values(), key=lambda path: str(path).casefold()))
779
+
780
+
781
+ def _merge_wiki_indexes(
782
+ root: Path,
783
+ indexes: list[CodebaseIndex],
784
+ ) -> CodebaseIndex:
785
+ discovery = DelphiProjectDiscovery(root=str(root))
786
+
787
+ def merge_values(target: list[str], values: Iterable[str]) -> None:
788
+ existing = {value.casefold() for value in target}
789
+ for value in values:
790
+ if value.casefold() not in existing:
791
+ target.append(value)
792
+ existing.add(value.casefold())
793
+
794
+ models: dict[str, SemanticModel] = {}
795
+ project_results: dict[str, ProjectIndexResult] = {}
796
+ configured_workers = 0
797
+ effective_workers = 0
798
+ files_completed = 0
799
+ elapsed_seconds = 0.0
800
+ fallbacks = 0
801
+ for index in indexes:
802
+ current = index.discovery
803
+ merge_values(discovery.project_files, current.project_files)
804
+ merge_values(discovery.config_files, current.config_files)
805
+ merge_values(discovery.search_paths, current.search_paths)
806
+ merge_values(discovery.include_paths, current.include_paths)
807
+ merge_values(discovery.defines, current.defines)
808
+ merge_values(discovery.source_files, current.source_files)
809
+ discovery.problems.extend(current.problems)
810
+ for target, source_origins in (
811
+ (discovery.search_path_origins, current.search_path_origins),
812
+ (discovery.include_path_origins, current.include_path_origins),
813
+ (discovery.define_origins, current.define_origins),
814
+ ):
815
+ for key, values in source_origins.items():
816
+ merge_values(target.setdefault(key, []), values)
817
+ models.update(index.models)
818
+ project_results.update(index.project_results)
819
+ configured_workers = max(
820
+ configured_workers,
821
+ index.parallel_stats.configured_workers,
822
+ )
823
+ effective_workers = max(
824
+ effective_workers,
825
+ index.parallel_stats.effective_workers,
826
+ )
827
+ files_completed += index.parallel_stats.files_completed
828
+ elapsed_seconds += index.parallel_stats.elapsed_seconds
829
+ fallbacks += index.parallel_stats.fallbacks
830
+
831
+ discovery.project_files.sort(key=str.casefold)
832
+ discovery.config_files.sort(key=str.casefold)
833
+ discovery.search_paths.sort(key=str.casefold)
834
+ discovery.include_paths.sort(key=str.casefold)
835
+ discovery.defines.sort(key=str.casefold)
836
+ discovery.source_files.sort(key=str.casefold)
837
+ for source in discovery.source_files:
838
+ discovery.unit_paths.setdefault(
839
+ Path(source).stem.casefold(),
840
+ [],
841
+ ).append(source)
842
+
843
+ symbol_index = SymbolIndex()
844
+ for model in models.values():
845
+ symbol_index.register_unit(model.unit_scope.name, model.unit_scope)
846
+ for model in models.values():
847
+ model.index = symbol_index
848
+
849
+ return CodebaseIndex(
850
+ root=str(root),
851
+ discovery=discovery,
852
+ models=models,
853
+ symbol_index=symbol_index,
854
+ project_results=project_results,
855
+ parallel_stats=ParallelBuildStats(
856
+ configured_workers,
857
+ effective_workers,
858
+ files_completed,
859
+ elapsed_seconds,
860
+ fallbacks,
861
+ ),
862
+ )
863
+
864
+
865
+ def _populate_wiki_references(index: CodebaseIndex) -> None:
866
+ reference_index = SymbolIndex()
867
+ outline_by_exact: dict[tuple[str, str, str, int, int], Symbol] = {}
868
+ outline_by_source: dict[tuple[str, str, str], Symbol] = {}
869
+ outline_ids: set[int] = set()
870
+ for model in index.models.values():
871
+ reference_index.register_unit(model.unit_scope.name, model.unit_scope)
872
+ for symbol in _iter_symbols(model.unit_scope):
873
+ outline_ids.add(id(symbol))
874
+ source_key = (
875
+ symbol.name.casefold(),
876
+ symbol.kind.value,
877
+ symbol.decl_range.file_name.casefold(),
878
+ )
879
+ outline_by_source.setdefault(source_key, symbol)
880
+ outline_by_exact[
881
+ (
882
+ *source_key,
883
+ symbol.name_range.start_line,
884
+ symbol.name_range.start_col,
885
+ )
886
+ ] = symbol
887
+ outline_units = dict(reference_index.units)
888
+ outline_name_counts = {
889
+ name: len(symbols)
890
+ for name, symbols in reference_index.name_index.items()
891
+ }
892
+
893
+ parser = DelphiParser(
894
+ include_paths=index.discovery.include_paths,
895
+ defines=index.discovery.defines,
896
+ include_loader=workspace_include_loader(
897
+ index.discovery.project_config,
898
+ index.discovery.include_paths,
899
+ ),
900
+ mode=ParserMode.TOLERANT,
901
+ )
902
+ for source_path, outline_model in index.models.items():
903
+ try:
904
+ source = read_source_text(Path(source_path))
905
+ parsed = parser.parse(
906
+ source,
907
+ source_path,
908
+ build_semantic=True,
909
+ index=reference_index,
910
+ )
911
+ except (OSError, UnicodeError):
912
+ continue
913
+ if parsed.semantic is None:
914
+ continue
915
+ parsed_unit_key = normalize_name(parsed.semantic.unit_scope.name)
916
+ registered_names = {
917
+ normalize_name(symbol.name)
918
+ for symbol in _iter_symbols(parsed.semantic.unit_scope)
919
+ }
920
+ try:
921
+ references: list[SymbolReference] = []
922
+ for reference in parsed.semantic.references:
923
+ resolved = reference.resolved
924
+ mapped = (
925
+ resolved
926
+ if resolved is not None and id(resolved) in outline_ids
927
+ else None
928
+ )
929
+ if resolved is not None and mapped is None:
930
+ source_key = (
931
+ resolved.name.casefold(),
932
+ resolved.kind.value,
933
+ resolved.decl_range.file_name.casefold(),
934
+ )
935
+ mapped = outline_by_exact.get(
936
+ (
937
+ *source_key,
938
+ resolved.name_range.start_line,
939
+ resolved.name_range.start_col,
940
+ )
941
+ ) or outline_by_source.get(source_key)
942
+ references.append(
943
+ SymbolReference(
944
+ name=reference.name,
945
+ kind=reference.kind,
946
+ ref_range=reference.ref_range,
947
+ scope=outline_model.unit_scope,
948
+ resolved=mapped,
949
+ )
950
+ )
951
+ outline_model.references = references
952
+ finally:
953
+ outline_unit = outline_units.get(parsed_unit_key)
954
+ if outline_unit is None:
955
+ reference_index.units.pop(parsed_unit_key, None)
956
+ else:
957
+ reference_index.units[parsed_unit_key] = outline_unit
958
+ for name in registered_names:
959
+ count = outline_name_counts.get(name)
960
+ if count is None:
961
+ reference_index.name_index.pop(name, None)
962
+ else:
963
+ del reference_index.name_index[name][count:]
964
+
965
+
966
+ def _emit_wiki_progress(
967
+ callback: WikiProgressCallback | None,
968
+ phase: str,
969
+ completed: int,
970
+ total: int | None,
971
+ path: str,
972
+ detail: str,
973
+ ) -> None:
974
+ if callback is not None:
975
+ callback(WikiProgressEvent(phase, completed, total, path, detail))
976
+
977
+
978
+ def _resolve_output_destination(output: str | Path) -> tuple[Path, Path]:
979
+ requested = Path(os.path.abspath(Path(output).expanduser()))
980
+ if requested.is_symlink():
981
+ raise WikiExportError(f"Wiki destination must not be a symlink: {requested}")
982
+ canonical = requested.parent.resolve() / requested.name
983
+ return requested, canonical
984
+
985
+
986
+ def _validate_paths(root: Path, output: Path, *, force: bool) -> None:
987
+ if not root.is_dir():
988
+ raise WikiExportError(f"Repository root is not a directory: {root}")
989
+ if output == root:
990
+ raise WikiExportError("Wiki destination must not be the repository root.")
991
+ if root.is_relative_to(output):
992
+ raise WikiExportError("Wiki destination must not contain the repository root.")
993
+ if output.is_symlink():
994
+ raise WikiExportError(f"Wiki destination must not be a symlink: {output}")
995
+ if output.exists() and not output.is_dir():
996
+ raise WikiExportError(f"Wiki destination already exists and is not a directory: {output}")
997
+ if output.exists() and not force and _directory_has_entries(output):
998
+ raise WikiExportError(
999
+ f"Wiki destination already exists: {output}. Pass --force to replace it."
1000
+ )
1001
+
1002
+
1003
+ def _directory_has_entries(path: Path) -> bool:
1004
+ try:
1005
+ next(path.iterdir())
1006
+ except StopIteration:
1007
+ return False
1008
+ return True
1009
+
1010
+
1011
+ def _install_completed_bundle(
1012
+ temporary: Path,
1013
+ output: Path,
1014
+ *,
1015
+ requested_output: Path,
1016
+ force: bool,
1017
+ ) -> None:
1018
+ _revalidate_output_destination(requested_output, output)
1019
+ if not output.exists():
1020
+ os.replace(temporary, output)
1021
+ return
1022
+ if not force and _directory_has_entries(output):
1023
+ raise WikiExportError(f"Wiki destination already exists: {output}")
1024
+
1025
+ backup = output.parent / f".{output.name}.backup-{os.getpid()}"
1026
+ suffix = 0
1027
+ while backup.exists():
1028
+ suffix += 1
1029
+ backup = output.parent / f".{output.name}.backup-{os.getpid()}-{suffix}"
1030
+ os.replace(output, backup)
1031
+ try:
1032
+ os.replace(temporary, output)
1033
+ except Exception:
1034
+ os.replace(backup, output)
1035
+ raise
1036
+ shutil.rmtree(backup)
1037
+
1038
+
1039
+ def _revalidate_output_destination(requested: Path, canonical: Path) -> None:
1040
+ if requested.is_symlink():
1041
+ raise WikiExportError(f"Wiki destination became a symlink: {requested}")
1042
+ current = requested.parent.resolve() / requested.name
1043
+ if current != canonical:
1044
+ raise WikiExportError(
1045
+ "Wiki destination parent changed while the bundle was generated."
1046
+ )
1047
+ if canonical.is_symlink():
1048
+ raise WikiExportError(f"Wiki destination must not be a symlink: {canonical}")
1049
+
1050
+
1051
+ class _WikiWriter:
1052
+ def __init__(
1053
+ self,
1054
+ index: CodebaseIndex,
1055
+ destination: Path,
1056
+ *,
1057
+ workers: int = 0,
1058
+ on_progress: WikiProgressCallback | None = None,
1059
+ ) -> None:
1060
+ self.index = index
1061
+ self.destination = destination
1062
+ self.workers = workers
1063
+ self._on_progress = on_progress
1064
+ self.documents = 0
1065
+ self._source_cache: dict[str, list[str]] = _BoundedSourceCache()
1066
+ self._display_path_cache: dict[str, str] = {}
1067
+ self._symbol_count = sum(1 for _symbol in self._iter_unique_symbols())
1068
+ self.units = self._unit_records()
1069
+ self._unit_by_source = {
1070
+ _normalized_source(record.source_path): record for record in self.units
1071
+ }
1072
+ self._unit_by_name = {record.name.casefold(): record for record in self.units}
1073
+ self._references_by_target: dict[int, list[SymbolReference]] = {}
1074
+ self._projects_by_source: dict[str, list[tuple[str, PurePosixPath]]] = {}
1075
+ self._metric_page_by_source: dict[str, PurePosixPath] = {}
1076
+ self._collect_references()
1077
+
1078
+ @property
1079
+ def symbol_count(self) -> int:
1080
+ return self._symbol_count
1081
+
1082
+ def write(self) -> None:
1083
+ self._write_root_index()
1084
+ self._write_overview()
1085
+ self._write_manifest()
1086
+ self._write_projects()
1087
+ self._write_metrics()
1088
+ self._write_units()
1089
+ self._write_symbols()
1090
+ self._write_references()
1091
+ self._write_problems()
1092
+ self._write_reference_docs()
1093
+
1094
+ def _iter_unique_symbols(self) -> Iterator[Symbol]:
1095
+ seen: set[bytes] = set()
1096
+ for symbol in _all_symbols(self.index):
1097
+ if symbol.kind.value == "unit":
1098
+ continue
1099
+ digest = hashlib.sha256(
1100
+ _symbol_identity(symbol).encode("utf-8")
1101
+ ).digest()
1102
+ if digest in seen:
1103
+ continue
1104
+ seen.add(digest)
1105
+ yield symbol
1106
+
1107
+ def _unit_records(self) -> list[_UnitRecord]:
1108
+ records = [
1109
+ _UnitRecord(
1110
+ source,
1111
+ model.unit_scope.name,
1112
+ PurePosixPath("units")
1113
+ / _document_name(model.unit_scope.name, _normalized_source(source)),
1114
+ )
1115
+ for source, model in self.index.models.items()
1116
+ ]
1117
+ return sorted(records, key=lambda record: (record.name.casefold(), record.source_path.casefold()))
1118
+
1119
+ def _collect_references(self) -> None:
1120
+ for model in self.index.models.values():
1121
+ for reference in model.references:
1122
+ if reference.resolved is None:
1123
+ continue
1124
+ key = id(reference.resolved)
1125
+ self._references_by_target.setdefault(key, []).append(reference)
1126
+ for references in self._references_by_target.values():
1127
+ references.sort(key=_reference_sort_key)
1128
+
1129
+ def _iter_unresolved_references(self) -> Iterator[SymbolReference]:
1130
+ seen: set[tuple[str, str, int, int, str]] = set()
1131
+ for _source, model in sorted(
1132
+ self.index.models.items(),
1133
+ key=lambda item: item[0].casefold(),
1134
+ ):
1135
+ for reference in sorted(model.references, key=_reference_sort_key):
1136
+ if reference.resolved is not None:
1137
+ continue
1138
+ key = (
1139
+ reference.name.casefold(),
1140
+ reference.ref_range.file_name,
1141
+ reference.ref_range.start_line,
1142
+ reference.ref_range.start_col,
1143
+ reference.kind.value,
1144
+ )
1145
+ if key in seen:
1146
+ continue
1147
+ seen.add(key)
1148
+ yield reference
1149
+
1150
+ def _progress(
1151
+ self,
1152
+ phase: str,
1153
+ completed: int,
1154
+ total: int | None,
1155
+ path: str,
1156
+ detail: str,
1157
+ ) -> None:
1158
+ _emit_wiki_progress(
1159
+ self._on_progress,
1160
+ phase,
1161
+ completed,
1162
+ total,
1163
+ path,
1164
+ detail,
1165
+ )
1166
+
1167
+ def _write_root_index(self) -> None:
1168
+ body = [
1169
+ "---",
1170
+ f'okf_version: "{OKF_VERSION}"',
1171
+ "---",
1172
+ "# Delphi Codebase Wiki",
1173
+ "",
1174
+ "Portable knowledge export generated by "
1175
+ f"`python-delphi-lsp {__version__}`.",
1176
+ "",
1177
+ "## Start here",
1178
+ "",
1179
+ f"- {_link('Overview', PurePosixPath('overview.md'))}",
1180
+ f"- {_link('Projects', PurePosixPath('projects/index.md'))}",
1181
+ f"- {_link('Units', PurePosixPath('units/index.md'))}",
1182
+ f"- {_link('Symbols', PurePosixPath('symbols/index.md'))}",
1183
+ f"- {_link('References', PurePosixPath('references/index.md'))}",
1184
+ f"- {_link('Problems', PurePosixPath('problems/index.md'))}",
1185
+ f"- {_link('Metrics', PurePosixPath('metrics/index.md'))}",
1186
+ f"- {_link('Query and graph reference', PurePosixPath('reference/index.md'))}",
1187
+ f"- {_link('Export manifest', PurePosixPath('manifest.md'))}",
1188
+ ]
1189
+ self._write(PurePosixPath("index.md"), "\n".join(body) + "\n")
1190
+
1191
+ def _write_overview(self) -> None:
1192
+ overview = layer_payload(self.index, "overview")
1193
+ body = [
1194
+ f"# {_escape( Path(self.index.root).name or 'Delphi workspace')}",
1195
+ "",
1196
+ f"- Repository root: `{_escape_code(self.index.root)}`",
1197
+ f"- Projects: {overview['project_count']}",
1198
+ f"- Sources: {overview['source_count']}",
1199
+ f"- Units: {overview['unit_count']}",
1200
+ f"- Symbols: {self.symbol_count}",
1201
+ "",
1202
+ "## Defines",
1203
+ "",
1204
+ *_bullet_code_values(overview["defines"]),
1205
+ "",
1206
+ "## Search paths",
1207
+ "",
1208
+ *_bullet_code_values(overview["search_paths"]),
1209
+ "",
1210
+ "## Include paths",
1211
+ "",
1212
+ *_bullet_code_values(overview["include_paths"]),
1213
+ "",
1214
+ "## Related knowledge",
1215
+ "",
1216
+ f"- {_link('Projects', PurePosixPath('projects/index.md'))}",
1217
+ f"- {_link('Problems', PurePosixPath('problems/index.md'))}",
1218
+ f"- {_link('Workspace metrics', PurePosixPath('metrics/workspace.md'))}",
1219
+ ]
1220
+ self._concept(
1221
+ PurePosixPath("overview.md"),
1222
+ concept_type="Delphi Codebase",
1223
+ title="Delphi codebase overview",
1224
+ description="Discovered projects, sources, units, paths, defines, and diagnostics.",
1225
+ tags=("delphi", "codebase", "overview"),
1226
+ body=body,
1227
+ )
1228
+
1229
+ def _write_manifest(self) -> None:
1230
+ body = [
1231
+ "# Export manifest",
1232
+ "",
1233
+ f"- Generator: `python-delphi-lsp/{__version__}`",
1234
+ f"- Open Knowledge Format: `{OKF_VERSION}`",
1235
+ f"- Projects: {len(self.index.discovery.project_files)}",
1236
+ f"- Units: {len(self.units)}",
1237
+ f"- Symbols: {self.symbol_count}",
1238
+ "",
1239
+ "## Coverage",
1240
+ "",
1241
+ "- Layered views: overview, projects, units, unit, symbols, symbol, "
1242
+ "implementation, references, problems, and metrics.",
1243
+ "- Semantic references are attached to their resolved symbol pages; "
1244
+ "unresolved references are retained separately.",
1245
+ "- Protocol v3 query, relation, CPG, pagination, and cache semantics "
1246
+ "are documented under the reference section.",
1247
+ "- Lazy CPG results are intentionally not expanded for every symbol, "
1248
+ "which avoids redundant parsing and unbounded bundle growth.",
1249
+ ]
1250
+ self._concept(
1251
+ PurePosixPath("manifest.md"),
1252
+ concept_type="Knowledge Bundle Manifest",
1253
+ title="Export manifest",
1254
+ description="Generator, format, counts, and coverage of this knowledge bundle.",
1255
+ tags=("manifest", "okf", "delphi"),
1256
+ body=body,
1257
+ )
1258
+
1259
+ def _write_projects(self) -> None:
1260
+ pages: list[tuple[str, PurePosixPath]] = []
1261
+ total = len(self.index.discovery.project_files)
1262
+ for ordinal, item in enumerate(self._project_items(), start=1):
1263
+ project_path = str(item["path"])
1264
+ title = Path(project_path).name
1265
+ page = PurePosixPath("projects") / _document_name(title, _normalized_source(project_path))
1266
+ pages.append((title, page))
1267
+ project_membership = (
1268
+ project_path,
1269
+ *(str(unit["path"]) for unit in item["parsed_units"]),
1270
+ )
1271
+ for source_path in project_membership:
1272
+ self._record_project_membership(source_path, ordinal, title, page)
1273
+ body = [
1274
+ f"# {_escape(title)}",
1275
+ "",
1276
+ f"- Path: `{_escape_code(self._display_path(project_path))}`",
1277
+ f"- Parsed units: {len(item['parsed_units'])}",
1278
+ f"- Include files: {len(item['include_files'])}",
1279
+ f"- Missing units: {len(item['not_found_units'])}",
1280
+ f"- Problems: {len(item['problems'])}",
1281
+ "",
1282
+ "## Parsed units",
1283
+ "",
1284
+ ]
1285
+ if item["parsed_units"]:
1286
+ for unit in item["parsed_units"]:
1287
+ unit_record = self._find_unit(str(unit["path"]), str(unit["name"]))
1288
+ label = f"{unit['name']} — {self._display_path(str(unit['path']))}"
1289
+ body.append(
1290
+ f"- {_link(label, unit_record.page) if unit_record else _escape(label)}"
1291
+ f"{' (parse error)' if unit['has_error'] else ''}"
1292
+ )
1293
+ else:
1294
+ body.append("- None")
1295
+ body.extend(["", "## Include files", ""])
1296
+ body.extend(
1297
+ f"- `{_escape_code(self._display_path(str(value['path'])))}`"
1298
+ for value in item["include_files"]
1299
+ )
1300
+ if not item["include_files"]:
1301
+ body.append("- None")
1302
+ body.extend(["", "## Missing units", ""])
1303
+ body.extend(f"- `{_escape_code(str(value))}`" for value in item["not_found_units"])
1304
+ if not item["not_found_units"]:
1305
+ body.append("- None")
1306
+ body.extend(["", "## Project-index problems", ""])
1307
+ body.extend(
1308
+ f"- **{_escape(str(problem['kind']))}:** "
1309
+ f"{_escape(str(problem['message']))} "
1310
+ f"(`{_escape_code(self._display_path(str(problem['file'])))}`)"
1311
+ for problem in item["problems"]
1312
+ )
1313
+ if not item["problems"]:
1314
+ body.append("- None")
1315
+ body.extend(["", "## Complete indexed data", "", _json_block(item)])
1316
+ self._concept(
1317
+ page,
1318
+ concept_type="Delphi Project",
1319
+ title=title,
1320
+ description=f"Project index for {title}.",
1321
+ tags=("delphi", "project"),
1322
+ body=body,
1323
+ )
1324
+ self._progress(
1325
+ "projects",
1326
+ ordinal,
1327
+ total,
1328
+ project_path,
1329
+ "project page written",
1330
+ )
1331
+ self._directory_index(
1332
+ PurePosixPath("projects/index.md"),
1333
+ "Projects",
1334
+ pages,
1335
+ empty="No Delphi project entry files were discovered.",
1336
+ )
1337
+
1338
+ def _project_items(self) -> Iterable[dict[str, Any]]:
1339
+ return layer_payload(self.index, "projects")["items"]
1340
+
1341
+ def _record_project_membership(
1342
+ self,
1343
+ source_path: str,
1344
+ ordinal: int,
1345
+ title: str,
1346
+ page: PurePosixPath,
1347
+ ) -> None:
1348
+ del ordinal
1349
+ memberships = self._projects_by_source.setdefault(
1350
+ _normalized_source(source_path), []
1351
+ )
1352
+ membership = (title, page)
1353
+ if membership not in memberships:
1354
+ memberships.append(membership)
1355
+
1356
+ def _project_memberships_for(
1357
+ self,
1358
+ source_path: str,
1359
+ ) -> list[tuple[str, PurePosixPath]]:
1360
+ return self._projects_by_source.get(_normalized_source(source_path), [])
1361
+
1362
+ def _write_units(self) -> None:
1363
+ pages: list[tuple[str, PurePosixPath]] = []
1364
+ total = len(self.units)
1365
+ for ordinal, unit in enumerate(self.units, start=1):
1366
+ pages.append((unit.name, unit.page))
1367
+ model = self.index.models[unit.source_path]
1368
+ symbols = sorted(
1369
+ _iter_symbols(model.unit_scope, include_unit=False),
1370
+ key=lambda symbol: (
1371
+ symbol.decl_range.start_line,
1372
+ symbol.decl_range.start_col,
1373
+ symbol.name.casefold(),
1374
+ symbol.kind.value,
1375
+ ),
1376
+ )
1377
+ references = sorted(model.references, key=_reference_sort_key)
1378
+ projects = self._project_memberships_for(unit.source_path)
1379
+ body = [
1380
+ f"# {_escape(unit.name)}",
1381
+ "",
1382
+ f"- Source: `{_escape_code(self._display_path(unit.source_path))}`",
1383
+ f"- Symbols: {len(symbols)}",
1384
+ f"- Semantic references: {len(references)}",
1385
+ "",
1386
+ "## Projects",
1387
+ "",
1388
+ ]
1389
+ body.extend(f"- {_link(title, page)}" for title, page in projects)
1390
+ if not projects:
1391
+ body.append("- No concrete project membership was resolved")
1392
+ body.extend(
1393
+ [
1394
+ "",
1395
+ "## Symbols",
1396
+ "",
1397
+ ]
1398
+ )
1399
+ body.extend(
1400
+ f"- {_link(symbol.name, _symbol_page(symbol))} "
1401
+ f"— `{symbol.kind.value}` at line {symbol.decl_range.start_line}"
1402
+ for symbol in symbols
1403
+ )
1404
+ if not symbols:
1405
+ body.append("- None")
1406
+ body.extend(["", "## References found in this unit", ""])
1407
+ body.extend(self._reference_lines(references))
1408
+ if not references:
1409
+ body.append("- None")
1410
+ metrics_page = self._metric_page_by_source.get(
1411
+ _normalized_source(unit.source_path)
1412
+ )
1413
+ if metrics_page is not None:
1414
+ body.extend(
1415
+ [
1416
+ "",
1417
+ "## Related knowledge",
1418
+ "",
1419
+ f"- {_link('Unit metrics', metrics_page)}",
1420
+ ]
1421
+ )
1422
+ self._concept(
1423
+ unit.page,
1424
+ concept_type="Delphi Unit",
1425
+ title=unit.name,
1426
+ description=f"Symbols and references in {unit.name}.",
1427
+ tags=("delphi", "unit"),
1428
+ body=body,
1429
+ )
1430
+ self._progress(
1431
+ "units",
1432
+ ordinal,
1433
+ total,
1434
+ unit.source_path,
1435
+ "unit page written",
1436
+ )
1437
+ pages.sort(key=lambda item: (item[0].casefold(), item[1].as_posix()))
1438
+ self._directory_index(
1439
+ PurePosixPath("units/index.md"),
1440
+ "Units",
1441
+ pages,
1442
+ empty="No Delphi units were indexed.",
1443
+ )
1444
+
1445
+ def _write_symbols(self) -> None:
1446
+ source_ordered_symbols = sorted(
1447
+ self._iter_unique_symbols(),
1448
+ key=lambda symbol: (
1449
+ _normalized_source(symbol.decl_range.file_name),
1450
+ symbol.decl_range.start_line,
1451
+ symbol.decl_range.start_col,
1452
+ symbol.name.casefold(),
1453
+ symbol.kind.value,
1454
+ ),
1455
+ )
1456
+ total = len(source_ordered_symbols)
1457
+ for ordinal, symbol in enumerate(source_ordered_symbols, start=1):
1458
+ references = self._references_by_target.get(id(symbol), [])
1459
+ page, source, body = self._symbol_body_prefix(symbol)
1460
+ body.extend(["", "## Semantic references", ""])
1461
+ body.extend(self._reference_lines(references))
1462
+ if not references:
1463
+ body.append("- None")
1464
+ self._concept(
1465
+ page,
1466
+ concept_type=f"Delphi {symbol.kind.value.replace('_', ' ').title()} Symbol",
1467
+ title=symbol.name,
1468
+ description=(
1469
+ f"{symbol.kind.value} {symbol.name} in {source} at "
1470
+ f"line {symbol.decl_range.start_line}."
1471
+ ),
1472
+ tags=("delphi", "symbol", symbol.kind.value),
1473
+ body=body,
1474
+ )
1475
+ self._progress(
1476
+ "symbols",
1477
+ ordinal,
1478
+ total,
1479
+ symbol.decl_range.file_name,
1480
+ f"symbol page written: {symbol.name}",
1481
+ )
1482
+
1483
+ name_ordered_symbols = sorted(
1484
+ self._iter_unique_symbols(),
1485
+ key=lambda symbol: (
1486
+ symbol.name.casefold(),
1487
+ _normalized_source(symbol.decl_range.file_name),
1488
+ symbol.decl_range.start_line,
1489
+ symbol.decl_range.start_col,
1490
+ symbol.kind.value,
1491
+ ),
1492
+ )
1493
+ self._directory_index_iter(
1494
+ PurePosixPath("symbols/index.md"),
1495
+ "Symbols",
1496
+ (
1497
+ (f"{symbol.name} ({symbol.kind.value})", _symbol_page(symbol))
1498
+ for symbol in name_ordered_symbols
1499
+ ),
1500
+ empty="No non-unit symbols were indexed.",
1501
+ )
1502
+
1503
+ def _symbol_body_prefix(
1504
+ self,
1505
+ symbol: Symbol,
1506
+ ) -> tuple[PurePosixPath, str, list[str]]:
1507
+ page = _symbol_page(symbol)
1508
+ source = self._display_path(symbol.decl_range.file_name)
1509
+ unit = self._unit_by_source.get(_normalized_source(symbol.decl_range.file_name))
1510
+ owner = _symbol_owner(symbol)
1511
+ owner_symbol = symbol.scope.owner
1512
+ owner_page = (
1513
+ _symbol_page(owner_symbol)
1514
+ if owner_symbol is not None
1515
+ and owner_symbol.kind.value != "unit"
1516
+ and owner_symbol is not symbol
1517
+ else None
1518
+ )
1519
+ children = self._direct_symbol_children(symbol)
1520
+ fragment = _source_fragment(symbol.decl_range, "declaration", self._source_cache)
1521
+ modifiers = sorted(value.value for value in symbol.modifiers)
1522
+ body = [
1523
+ f"# {_escape(symbol.name)}",
1524
+ "",
1525
+ f"- Kind: `{symbol.kind.value}`",
1526
+ f"- Type: `{_escape_code(symbol.type_ref.display_name())}`",
1527
+ f"- Visibility: `{symbol.visibility.value}`",
1528
+ (
1529
+ f"- Owner: "
1530
+ f"{_link(owner, owner_page) if owner_page else f'`{_escape_code(owner)}`'}"
1531
+ ),
1532
+ f"- Unit: {_link(unit.name, unit.page) if unit else '`unknown`'}",
1533
+ (
1534
+ f"- Location: `{_escape_code(source)}:{symbol.decl_range.start_line}:"
1535
+ f"{symbol.decl_range.start_col}`"
1536
+ ),
1537
+ (
1538
+ f"- Range: `{symbol.decl_range.start_line}:{symbol.decl_range.start_col}-"
1539
+ f"{symbol.decl_range.end_line}:{symbol.decl_range.end_col}`"
1540
+ ),
1541
+ f"- Modifiers: {', '.join(f'`{value}`' for value in modifiers) or 'None'}",
1542
+ (
1543
+ f"- Base types: "
1544
+ f"{', '.join(f'`{_escape_code(value.display_name())}`' for value in symbol.base_types) or 'None'}"
1545
+ ),
1546
+ ]
1547
+ if symbol.doc:
1548
+ body.extend(["", "## Documentation", "", symbol.doc])
1549
+ if symbol.attributes:
1550
+ body.extend(["", "## Attributes", "", _json_block(symbol.attributes)])
1551
+ body.extend(["", "## Declaration or implementation", ""])
1552
+ if fragment is None:
1553
+ body.append("Source fragment unavailable.")
1554
+ else:
1555
+ body.extend(
1556
+ [
1557
+ (
1558
+ f"`{_escape_code(self._display_path(str(fragment['range']['path'])))}:"
1559
+ f"{fragment['range']['start_line']}`"
1560
+ ),
1561
+ "",
1562
+ _code_block(str(fragment["text"]), "pascal"),
1563
+ ]
1564
+ )
1565
+ body.extend(["", "## Members", ""])
1566
+ body.extend(
1567
+ f"- {_link(child.name, _symbol_page(child))} — `{child.kind.value}`"
1568
+ for child in children
1569
+ )
1570
+ if not children:
1571
+ body.append("- None")
1572
+ return page, source, body
1573
+
1574
+ @staticmethod
1575
+ def _direct_symbol_children(symbol: Symbol) -> list[Symbol]:
1576
+ if symbol.member_scope is None:
1577
+ return []
1578
+ children = [
1579
+ child
1580
+ for symbols in symbol.member_scope.symbols.values()
1581
+ for child in symbols
1582
+ if child is not symbol and child.kind.value != "unit"
1583
+ ]
1584
+ return sorted(
1585
+ children,
1586
+ key=lambda child: (
1587
+ child.name.casefold(),
1588
+ child.kind.value,
1589
+ child.decl_range.start_line,
1590
+ child.decl_range.start_col,
1591
+ ),
1592
+ )
1593
+
1594
+ def _write_references(self) -> None:
1595
+ resolved_symbols = sorted(
1596
+ (
1597
+ symbol
1598
+ for symbol in self._iter_unique_symbols()
1599
+ if id(symbol) in self._references_by_target
1600
+ ),
1601
+ key=lambda symbol: (
1602
+ symbol.name.casefold(),
1603
+ _normalized_source(symbol.decl_range.file_name),
1604
+ symbol.decl_range.start_line,
1605
+ symbol.decl_range.start_col,
1606
+ ),
1607
+ )
1608
+ unresolved_page = PurePosixPath("references/unresolved.md")
1609
+ unresolved_count = sum(1 for _ in self._iter_unresolved_references())
1610
+
1611
+ def unresolved_body() -> Iterator[str]:
1612
+ yield "# Unresolved semantic references"
1613
+ yield ""
1614
+ yield (
1615
+ "These names were observed by the tolerant semantic index but "
1616
+ "could not be resolved soundly in the current workspace."
1617
+ )
1618
+ yield ""
1619
+ if unresolved_count:
1620
+ yield from self._iter_reference_lines(
1621
+ self._iter_unresolved_references()
1622
+ )
1623
+ else:
1624
+ yield "- None"
1625
+
1626
+ self._concept_iter(
1627
+ unresolved_page,
1628
+ concept_type="Delphi Unresolved References",
1629
+ title="Unresolved semantic references",
1630
+ description="References that remain unresolved in the tolerant semantic index.",
1631
+ tags=("delphi", "references", "diagnostics"),
1632
+ body=unresolved_body(),
1633
+ )
1634
+
1635
+ def reference_index_lines() -> Iterator[str]:
1636
+ yield "# References"
1637
+ yield ""
1638
+ yield "Resolved references are embedded on their target symbol pages."
1639
+ yield ""
1640
+ if resolved_symbols:
1641
+ for symbol in resolved_symbols:
1642
+ count = len(self._references_by_target[id(symbol)])
1643
+ yield f"- {_link(f'{symbol.name} ({count})', _symbol_page(symbol))}"
1644
+ else:
1645
+ yield "- No resolved references"
1646
+ yield ""
1647
+ yield f"- {_link('Unresolved references', unresolved_page)}"
1648
+
1649
+ self._write_lines(
1650
+ PurePosixPath("references/index.md"),
1651
+ reference_index_lines(),
1652
+ )
1653
+ self._progress(
1654
+ "references",
1655
+ 1,
1656
+ 1,
1657
+ str(unresolved_page),
1658
+ (
1659
+ f"reference pages written ({len(resolved_symbols)} resolved "
1660
+ f"targets, {unresolved_count} unresolved)"
1661
+ ),
1662
+ )
1663
+
1664
+ def _write_problems(self) -> None:
1665
+ payload = layer_payload(self.index, "problems")
1666
+ page = PurePosixPath("problems/workspace.md")
1667
+ body = ["# Workspace problems", "", f"Problems: {len(payload['items'])}", ""]
1668
+ body.extend(
1669
+ f"- **{_escape(str(item['kind']))}:** {_escape(str(item['message']))} "
1670
+ f"(`{_escape_code(self._display_path(str(item.get('origin', ''))))}`)"
1671
+ for item in payload["items"]
1672
+ )
1673
+ if not payload["items"]:
1674
+ body.append("- None")
1675
+ body.extend(["", "## Complete problem data", "", _json_block(payload["items"])])
1676
+ self._concept(
1677
+ page,
1678
+ concept_type="Delphi Workspace Problems",
1679
+ title="Workspace problems",
1680
+ description="Discovery and deep-project indexing problems.",
1681
+ tags=("delphi", "problems", "diagnostics"),
1682
+ body=body,
1683
+ )
1684
+ self._directory_index(
1685
+ PurePosixPath("problems/index.md"),
1686
+ "Problems",
1687
+ [("Workspace problems", page)],
1688
+ empty="No problem report was generated.",
1689
+ )
1690
+
1691
+ def _write_metrics(self) -> None:
1692
+ project_name = "Workspace"
1693
+ if len(self.index.discovery.project_files) == 1:
1694
+ project_name = Path(self.index.discovery.project_files[0]).stem
1695
+ metric_source_total = len(self.index.discovery.source_files)
1696
+ self._progress(
1697
+ "metrics",
1698
+ 0,
1699
+ metric_source_total or 1,
1700
+ self.index.root,
1701
+ "calculating source metrics",
1702
+ )
1703
+ metrics = build_path_metrics(
1704
+ self.index.root,
1705
+ self.index.discovery.source_files,
1706
+ defines=self.index.discovery.defines,
1707
+ include_paths=self.index.discovery.include_paths,
1708
+ project_config=self.index.discovery.project_config,
1709
+ project_name=project_name,
1710
+ workers=self.workers,
1711
+ on_progress=lambda completed, total, path: self._progress(
1712
+ "metrics",
1713
+ completed,
1714
+ total,
1715
+ path,
1716
+ "source metrics calculated",
1717
+ ),
1718
+ )
1719
+ payload = {
1720
+ "project": metrics.to_mapping(),
1721
+ "items": [unit.to_mapping(detail=True) for unit in metrics.file_metrics],
1722
+ }
1723
+ workspace_page = PurePosixPath("metrics/workspace.md")
1724
+ project = payload["project"]
1725
+ workspace_body = [
1726
+ "# Workspace metrics",
1727
+ "",
1728
+ f"- Total LOC: {project['total_loc']}",
1729
+ f"- Total LOC with includes: {project['total_loc_with_includes']}",
1730
+ f"- Units: {project['unit_count']}",
1731
+ f"- Maintainability index: {project['maintainability_index']:.2f}",
1732
+ "",
1733
+ "## Complete metric data",
1734
+ "",
1735
+ _json_block(project),
1736
+ ]
1737
+ self._concept(
1738
+ workspace_page,
1739
+ concept_type="Delphi Project Metrics",
1740
+ title="Workspace metrics",
1741
+ description="Aggregate architecture and maintainability metrics.",
1742
+ tags=("delphi", "metrics", "architecture"),
1743
+ body=workspace_body,
1744
+ )
1745
+
1746
+ unit_pages: list[tuple[str, PurePosixPath]] = []
1747
+ metric_item_total = len(payload["items"])
1748
+ for ordinal, item in enumerate(payload["items"], start=1):
1749
+ unit = self._find_unit(str(item["path"]), str(item["name"]))
1750
+ metric_identity = (
1751
+ _normalized_source(unit.source_path)
1752
+ if unit is not None
1753
+ else _normalized_source(str(item["path"]))
1754
+ )
1755
+ page = PurePosixPath("metrics/units") / _document_name(
1756
+ str(item["name"]), metric_identity
1757
+ )
1758
+ if unit is not None:
1759
+ self._metric_page_by_source[
1760
+ _normalized_source(unit.source_path)
1761
+ ] = page
1762
+ unit_pages.append((str(item["name"]), page))
1763
+ body = [
1764
+ f"# {_escape(str(item['name']))} metrics",
1765
+ "",
1766
+ f"- Unit: {_link(str(item['name']), unit.page) if unit else _escape(str(item['name']))}",
1767
+ f"- Source: `{_escape_code(self._display_path(str(item['path'])))}`",
1768
+ f"- LOC: {item['lines']['total_lines']}",
1769
+ f"- Cyclomatic maximum: {item['cyclomatic']['maximum']}",
1770
+ f"- Maintainability index: {item['maintainability_index']:.2f}",
1771
+ f"- Instability: {item['instability']:.3f}",
1772
+ f"- Abstractness: {item['abstractness']:.3f}",
1773
+ f"- Distance: {item['distance']:.3f}",
1774
+ "",
1775
+ "## Complete metric data",
1776
+ "",
1777
+ _json_block(item),
1778
+ ]
1779
+ self._concept(
1780
+ page,
1781
+ concept_type="Delphi Unit Metrics",
1782
+ title=f"{item['name']} metrics",
1783
+ description=f"Architecture and maintainability metrics for {item['name']}.",
1784
+ tags=("delphi", "metrics", "unit"),
1785
+ body=body,
1786
+ )
1787
+ self._progress(
1788
+ "metric-pages",
1789
+ ordinal,
1790
+ metric_item_total,
1791
+ str(item["path"]),
1792
+ "unit metric page written",
1793
+ )
1794
+ self._directory_index(
1795
+ PurePosixPath("metrics/units/index.md"),
1796
+ "Unit metrics",
1797
+ unit_pages,
1798
+ empty="No per-unit metrics were calculated.",
1799
+ )
1800
+ self._directory_index(
1801
+ PurePosixPath("metrics/index.md"),
1802
+ "Metrics",
1803
+ [("Workspace metrics", workspace_page), ("Unit metrics", PurePosixPath("metrics/units/index.md"))],
1804
+ empty="No metrics were calculated.",
1805
+ )
1806
+
1807
+ def _write_reference_docs(self) -> None:
1808
+ protocol_page = PurePosixPath("reference/protocol.md")
1809
+ protocol_body = [
1810
+ "# Agent Protocol v3",
1811
+ "",
1812
+ f"- Schema version: `{SCHEMA_VERSION}`",
1813
+ f"- Actions: {_inline_code(SUPPORTED_ACTIONS)}",
1814
+ f"- Detail modes: {_inline_code(SUPPORTED_DETAILS)}",
1815
+ f"- Relations: {_inline_code(SUPPORTED_RELATIONS)}",
1816
+ f"- Graphs: {_inline_code(SUPPORTED_GRAPHS)}",
1817
+ f"- Directions: {_inline_code(SUPPORTED_DIRECTIONS)}",
1818
+ "",
1819
+ "Query pages are revision-bound and may use cursors. Live requests "
1820
+ "bound result size with `max_items` and `max_chars`; this static "
1821
+ "bundle instead stores each unique exported fact once.",
1822
+ ]
1823
+ self._concept(
1824
+ protocol_page,
1825
+ concept_type="Delphi Agent Protocol Reference",
1826
+ title="Agent Protocol v3",
1827
+ description="Supported query actions, detail modes, relations, and pagination.",
1828
+ tags=("delphi", "protocol", "query"),
1829
+ body=protocol_body,
1830
+ )
1831
+
1832
+ cpg_page = PurePosixPath("reference/cpg.md")
1833
+ cpg_body = [
1834
+ "# Code Property Graph",
1835
+ "",
1836
+ f"Available graph projections: {_inline_code(SUPPORTED_GRAPHS)}.",
1837
+ f"Traversal directions: {_inline_code(SUPPORTED_DIRECTIONS)}.",
1838
+ "",
1839
+ "CPG queries lazily combine AST, control-flow, data-flow, and call "
1840
+ "edges for a focused target. They remain live queries because "
1841
+ "eagerly materializing every overlapping target would repeat source "
1842
+ "parsing and make a multi-million-line export unbounded.",
1843
+ "",
1844
+ "The exporter retains symbol locations, implementations, semantic "
1845
+ "references, metrics, and the supported graph contract needed to "
1846
+ "select a target for a subsequent live CPG query.",
1847
+ ]
1848
+ self._concept(
1849
+ cpg_page,
1850
+ concept_type="Delphi Code Property Graph Reference",
1851
+ title="Code Property Graph",
1852
+ description="Available CPG projections, traversal controls, and export boundaries.",
1853
+ tags=("delphi", "cpg", "graph"),
1854
+ body=cpg_body,
1855
+ )
1856
+
1857
+ cache_page = PurePosixPath("reference/cache.md")
1858
+ cache_body = [
1859
+ "# Cache behavior",
1860
+ "",
1861
+ "The shared cache daemon prewarms a repository navigation context, "
1862
+ "reuses it across CLI queries, invalidates it when the workspace "
1863
+ "revision changes, and keeps memory bounded by its configured limit.",
1864
+ "",
1865
+ "The wiki export is independent of daemon lifetime. It performs one "
1866
+ "parallel index build and writes a portable on-disk snapshot.",
1867
+ ]
1868
+ self._concept(
1869
+ cache_page,
1870
+ concept_type="Delphi Cache Reference",
1871
+ title="Cache behavior",
1872
+ description="Relationship between the live bounded cache and static wiki exports.",
1873
+ tags=("delphi", "cache", "performance"),
1874
+ body=cache_body,
1875
+ )
1876
+
1877
+ layers_page = PurePosixPath("reference/layers.md")
1878
+ layers_body = [
1879
+ "# Layer mapping",
1880
+ "",
1881
+ "- `overview` → `overview.md`",
1882
+ "- `projects` → `projects/`",
1883
+ "- `units` and `unit` → `units/`",
1884
+ "- `symbols`, `symbol`, and `implementation` → `symbols/`",
1885
+ "- `references` → symbol pages and `references/`",
1886
+ "- `problems` → `problems/`",
1887
+ "- `metrics` → `metrics/`",
1888
+ ]
1889
+ self._concept(
1890
+ layers_page,
1891
+ concept_type="Delphi Layer Export Reference",
1892
+ title="Layer mapping",
1893
+ description="Mapping from live layered views to static wiki concepts.",
1894
+ tags=("delphi", "layers", "export"),
1895
+ body=layers_body,
1896
+ )
1897
+ self._directory_index(
1898
+ PurePosixPath("reference/index.md"),
1899
+ "Query and graph reference",
1900
+ [
1901
+ ("Agent Protocol v3", protocol_page),
1902
+ ("Layer mapping", layers_page),
1903
+ ("Code Property Graph", cpg_page),
1904
+ ("Cache behavior", cache_page),
1905
+ ],
1906
+ empty="No reference documents were generated.",
1907
+ )
1908
+
1909
+ def _reference_lines(self, references: Iterable[SymbolReference]) -> list[str]:
1910
+ return list(self._iter_reference_lines(references))
1911
+
1912
+ def _iter_reference_lines(
1913
+ self,
1914
+ references: Iterable[SymbolReference],
1915
+ ) -> Iterator[str]:
1916
+ for reference in references:
1917
+ yield self._reference_line(
1918
+ reference,
1919
+ target_key=_reference_target_key(reference),
1920
+ )
1921
+
1922
+ def _reference_line(
1923
+ self,
1924
+ reference: SymbolReference,
1925
+ *,
1926
+ target_key: str | None,
1927
+ ) -> str:
1928
+ location = (
1929
+ f"{self._display_path(reference.ref_range.file_name)}:"
1930
+ f"{reference.ref_range.start_line}:{reference.ref_range.start_col}"
1931
+ )
1932
+ target = reference.resolved
1933
+ target_text = (
1934
+ f" → {_link(target.name, PurePosixPath('symbols') / _document_name(target.name, target_key))}"
1935
+ if target is not None and target_key is not None
1936
+ else ""
1937
+ )
1938
+ return (
1939
+ f"- `{reference.kind.value}` `{_escape_code(reference.name)}` at "
1940
+ f"`{_escape_code(location)}`{target_text}"
1941
+ )
1942
+
1943
+ def _find_unit(self, source: str, name: str) -> _UnitRecord | None:
1944
+ source_path = Path(source).expanduser()
1945
+ if not source_path.is_absolute():
1946
+ source_path = Path(self.index.root) / source_path
1947
+ return self._unit_by_source.get(
1948
+ _normalized_source(str(source_path))
1949
+ ) or self._unit_by_name.get(name.casefold())
1950
+
1951
+ def _display_path(self, value: str) -> str:
1952
+ if not value:
1953
+ return ""
1954
+ cached = self._display_path_cache.get(value)
1955
+ if cached is not None:
1956
+ return cached
1957
+ path = Path(value)
1958
+ try:
1959
+ displayed = path.resolve().relative_to(Path(self.index.root)).as_posix()
1960
+ except (OSError, ValueError):
1961
+ displayed = path.as_posix()
1962
+ self._display_path_cache[value] = displayed
1963
+ return displayed
1964
+
1965
+ def _concept(
1966
+ self,
1967
+ page: PurePosixPath,
1968
+ *,
1969
+ concept_type: str,
1970
+ title: str,
1971
+ description: str,
1972
+ tags: tuple[str, ...],
1973
+ body: list[str],
1974
+ ) -> None:
1975
+ frontmatter = [
1976
+ "---",
1977
+ f"type: {json.dumps(concept_type, ensure_ascii=False)}",
1978
+ f"title: {json.dumps(title, ensure_ascii=False)}",
1979
+ f"description: {json.dumps(description, ensure_ascii=False)}",
1980
+ f"tags: {json.dumps(list(tags), ensure_ascii=False)}",
1981
+ 'status: "stable"',
1982
+ "---",
1983
+ ]
1984
+ self._write(page, "\n".join([*frontmatter, *body]).rstrip() + "\n")
1985
+
1986
+ def _concept_iter(
1987
+ self,
1988
+ page: PurePosixPath,
1989
+ *,
1990
+ concept_type: str,
1991
+ title: str,
1992
+ description: str,
1993
+ tags: tuple[str, ...],
1994
+ body: Iterable[str],
1995
+ ) -> None:
1996
+ frontmatter = (
1997
+ "---",
1998
+ f"type: {json.dumps(concept_type, ensure_ascii=False)}",
1999
+ f"title: {json.dumps(title, ensure_ascii=False)}",
2000
+ f"description: {json.dumps(description, ensure_ascii=False)}",
2001
+ f"tags: {json.dumps(list(tags), ensure_ascii=False)}",
2002
+ 'status: "stable"',
2003
+ "---",
2004
+ )
2005
+ self._write_lines(page, chain(frontmatter, body))
2006
+
2007
+ def _directory_index(
2008
+ self,
2009
+ page: PurePosixPath,
2010
+ title: str,
2011
+ items: list[tuple[str, PurePosixPath]],
2012
+ *,
2013
+ empty: str,
2014
+ ) -> None:
2015
+ body = [f"# {_escape(title)}", ""]
2016
+ body.extend(f"- {_link(label, target)}" for label, target in items)
2017
+ if not items:
2018
+ body.append(empty)
2019
+ self._write(page, "\n".join(body).rstrip() + "\n")
2020
+
2021
+ def _directory_index_iter(
2022
+ self,
2023
+ page: PurePosixPath,
2024
+ title: str,
2025
+ items: Iterable[tuple[str, PurePosixPath]],
2026
+ *,
2027
+ empty: str,
2028
+ ) -> None:
2029
+ iterator = iter(items)
2030
+ first = next(iterator, None)
2031
+
2032
+ def lines() -> Iterator[str]:
2033
+ yield f"# {_escape(title)}"
2034
+ yield ""
2035
+ if first is None:
2036
+ yield empty
2037
+ return
2038
+ yield f"- {_link(first[0], first[1])}"
2039
+ for label, target in iterator:
2040
+ yield f"- {_link(label, target)}"
2041
+
2042
+ self._write_lines(page, lines())
2043
+
2044
+ def _write(self, relative_path: PurePosixPath, text: str) -> None:
2045
+ target = self.destination.joinpath(*relative_path.parts)
2046
+ target.parent.mkdir(parents=True, exist_ok=True)
2047
+ target.write_text(text, encoding="utf-8", newline="\n")
2048
+ self.documents += 1
2049
+
2050
+ def _write_lines(
2051
+ self,
2052
+ relative_path: PurePosixPath,
2053
+ lines: Iterable[str],
2054
+ ) -> None:
2055
+ target = self.destination.joinpath(*relative_path.parts)
2056
+ target.parent.mkdir(parents=True, exist_ok=True)
2057
+ with target.open("w", encoding="utf-8", newline="\n") as stream:
2058
+ for line in lines:
2059
+ stream.write(line)
2060
+ stream.write("\n")
2061
+ self.documents += 1
2062
+
2063
+
2064
+ class _StreamingWikiWriter(_WikiWriter):
2065
+ """Write a wiki while retaining at most the current unit and reference group."""
2066
+
2067
+ def __init__(
2068
+ self,
2069
+ index: CodebaseIndex,
2070
+ destination: Path,
2071
+ *,
2072
+ models: _StoredWikiModels,
2073
+ projects: _WikiProjectStore,
2074
+ unique_catalog: Path,
2075
+ name_catalog: Path,
2076
+ symbol_count: int,
2077
+ workspace: Path,
2078
+ guard: WikiMemoryGuard,
2079
+ strategy: _WikiExecutionStrategy,
2080
+ on_progress: WikiProgressCallback | None,
2081
+ ) -> None:
2082
+ self.index = index
2083
+ self.destination = destination
2084
+ self.workers = strategy.workers
2085
+ self._on_progress = on_progress
2086
+ self.documents = 0
2087
+ self._source_cache: dict[str, list[str]] = _BoundedSourceCache()
2088
+ self._display_path_cache: dict[str, str] = {}
2089
+ self._symbol_count = symbol_count
2090
+ self.units = models.unit_records()
2091
+ self._unit_by_source = {
2092
+ _normalized_source(record.source_path): record for record in self.units
2093
+ }
2094
+ self._unit_by_name = {record.name.casefold(): record for record in self.units}
2095
+ self._references_by_target: dict[int, list[SymbolReference]] = {}
2096
+ self._projects_by_source: dict[str, list[tuple[str, PurePosixPath]]] = {}
2097
+ self._metric_page_by_source: dict[str, PurePosixPath] = {}
2098
+ self.models = models
2099
+ self.projects = projects
2100
+ self.unique_catalog = unique_catalog
2101
+ self.name_catalog = name_catalog
2102
+ self.workspace = workspace
2103
+ self.guard = guard
2104
+ self.strategy = strategy
2105
+ self.memberships = _ProjectMembershipStore(workspace / "membership.sqlite3")
2106
+ self.raw_references = workspace / "references-raw.jsonl"
2107
+ self.raw_unresolved = workspace / "unresolved-raw.jsonl"
2108
+
2109
+ def close(self) -> None:
2110
+ self.memberships.close()
2111
+
2112
+ def clear_caches(self) -> None:
2113
+ self.models.clear_cache()
2114
+ self._source_cache.clear()
2115
+ self._display_path_cache.clear()
2116
+
2117
+ def write(self) -> None:
2118
+ self._write_root_index()
2119
+ self._write_overview()
2120
+ self._write_manifest()
2121
+ self._write_projects()
2122
+ self.memberships.commit()
2123
+ self._write_metrics()
2124
+ self.guard.check(phase="metrics")
2125
+ self._write_streaming_units_and_symbol_bases()
2126
+ sorted_references = self._sort_references()
2127
+ self._finish_symbol_pages_and_references(sorted_references)
2128
+ self._write_streaming_problems()
2129
+ self._write_reference_docs()
2130
+
2131
+ def _project_items(self) -> Iterable[dict[str, Any]]:
2132
+ return self.projects.items()
2133
+
2134
+ def _record_project_membership(
2135
+ self,
2136
+ source_path: str,
2137
+ ordinal: int,
2138
+ title: str,
2139
+ page: PurePosixPath,
2140
+ ) -> None:
2141
+ self.memberships.add(source_path, ordinal, title, page)
2142
+
2143
+ def _project_memberships_for(
2144
+ self,
2145
+ source_path: str,
2146
+ ) -> list[tuple[str, PurePosixPath]]:
2147
+ return self.memberships.for_source(source_path)
2148
+
2149
+ def _write_streaming_units_and_symbol_bases(self) -> None:
2150
+ parser = DelphiParser(
2151
+ include_paths=self.index.discovery.include_paths,
2152
+ defines=self.index.discovery.defines,
2153
+ include_loader=workspace_include_loader(
2154
+ self.index.discovery.project_config,
2155
+ self.index.discovery.include_paths,
2156
+ ),
2157
+ mode=ParserMode.TOLERANT,
2158
+ )
2159
+ unit_pages: list[tuple[str, PurePosixPath]] = []
2160
+ self.raw_references.touch()
2161
+ self.raw_unresolved.touch()
2162
+ total = len(self.units)
2163
+ source_ordinal = {source: ordinal for ordinal, source in enumerate(self.models)}
2164
+ with (
2165
+ self.raw_references.open("a", encoding="utf-8", newline="\n") as resolved_stream,
2166
+ self.raw_unresolved.open("a", encoding="utf-8", newline="\n") as unresolved_stream,
2167
+ ):
2168
+ for ordinal, unit in enumerate(self.units, start=1):
2169
+ outline_model = self.models[unit.source_path]
2170
+ references = self._parse_unit_references(
2171
+ unit,
2172
+ outline_model,
2173
+ parser,
2174
+ )
2175
+ indexed_references = list(enumerate(references))
2176
+ indexed_references.sort(key=lambda item: _reference_sort_key(item[1]))
2177
+ prepared_references = [
2178
+ (
2179
+ reference_ordinal,
2180
+ reference,
2181
+ _reference_target_key(reference),
2182
+ )
2183
+ for reference_ordinal, reference in indexed_references
2184
+ ]
2185
+ reference_lines = [
2186
+ self._reference_line(reference, target_key=target_key)
2187
+ for _reference_ordinal, reference, target_key in prepared_references
2188
+ ]
2189
+ self._write_streaming_unit_page(
2190
+ unit,
2191
+ outline_model,
2192
+ reference_lines,
2193
+ )
2194
+ unit_pages.append((unit.name, unit.page))
2195
+ self._write_symbol_bases(outline_model)
2196
+ model_ordinal = source_ordinal[unit.source_path]
2197
+ for (reference_ordinal, reference, target_key), line in zip(
2198
+ prepared_references,
2199
+ reference_lines,
2200
+ strict=True,
2201
+ ):
2202
+ record = self._reference_record(
2203
+ reference,
2204
+ line=line,
2205
+ model_ordinal=model_ordinal,
2206
+ reference_ordinal=reference_ordinal,
2207
+ target_key=target_key,
2208
+ )
2209
+ if record["resolved"] and record.get("target_key") is None:
2210
+ continue
2211
+ stream = resolved_stream if record["resolved"] else unresolved_stream
2212
+ stream.write(
2213
+ json.dumps(record, ensure_ascii=False, separators=(",", ":"))
2214
+ )
2215
+ stream.write("\n")
2216
+ del indexed_references
2217
+ del prepared_references
2218
+ del references
2219
+ del outline_model
2220
+ if ordinal % _STREAMING_UNIT_GC_INTERVAL == 0:
2221
+ gc.collect()
2222
+ self._progress(
2223
+ "units",
2224
+ ordinal,
2225
+ total,
2226
+ unit.source_path,
2227
+ "unit and symbol pages streamed",
2228
+ )
2229
+ self.guard.check(phase="unit export")
2230
+ gc.collect()
2231
+ self.guard.check(phase="unit export", force_process_tree=True)
2232
+ unit_pages.sort(key=lambda item: (item[0].casefold(), item[1].as_posix()))
2233
+ self._directory_index(
2234
+ PurePosixPath("units/index.md"),
2235
+ "Units",
2236
+ unit_pages,
2237
+ empty="No Delphi units were indexed.",
2238
+ )
2239
+
2240
+ def _parse_unit_references(
2241
+ self,
2242
+ unit: _UnitRecord,
2243
+ outline_model: SemanticModel,
2244
+ parser: DelphiParser,
2245
+ ) -> list[SymbolReference]:
2246
+ lazy_index = _LazyWikiSymbolIndex(self.models)
2247
+ outline_by_exact: dict[tuple[str, str, str, int, int], Symbol] = {}
2248
+ outline_by_source: dict[tuple[str, str, str], Symbol] = {}
2249
+ for symbol in _iter_symbols(outline_model.unit_scope):
2250
+ source_key = (
2251
+ symbol.name.casefold(),
2252
+ symbol.kind.value,
2253
+ symbol.decl_range.file_name.casefold(),
2254
+ )
2255
+ outline_by_source.setdefault(source_key, symbol)
2256
+ outline_by_exact[
2257
+ (
2258
+ *source_key,
2259
+ symbol.name_range.start_line,
2260
+ symbol.name_range.start_col,
2261
+ )
2262
+ ] = symbol
2263
+ try:
2264
+ source = read_source_text(Path(unit.source_path))
2265
+ parsed = parser.parse(
2266
+ source,
2267
+ unit.source_path,
2268
+ build_semantic=True,
2269
+ index=lazy_index,
2270
+ )
2271
+ except (OSError, UnicodeError):
2272
+ return []
2273
+ if parsed.semantic is None:
2274
+ return []
2275
+ mapped_references: list[SymbolReference] = []
2276
+ for reference in parsed.semantic.references:
2277
+ resolved = reference.resolved
2278
+ mapped = (
2279
+ resolved
2280
+ if resolved is not None
2281
+ and _scope_root(resolved.scope) is not parsed.semantic.unit_scope
2282
+ else None
2283
+ )
2284
+ if resolved is not None and mapped is None:
2285
+ source_key = (
2286
+ resolved.name.casefold(),
2287
+ resolved.kind.value,
2288
+ resolved.decl_range.file_name.casefold(),
2289
+ )
2290
+ mapped = outline_by_exact.get(
2291
+ (
2292
+ *source_key,
2293
+ resolved.name_range.start_line,
2294
+ resolved.name_range.start_col,
2295
+ )
2296
+ ) or outline_by_source.get(source_key)
2297
+ mapped_references.append(
2298
+ SymbolReference(
2299
+ name=reference.name,
2300
+ kind=reference.kind,
2301
+ ref_range=reference.ref_range,
2302
+ scope=outline_model.unit_scope,
2303
+ resolved=mapped,
2304
+ )
2305
+ )
2306
+ return mapped_references
2307
+
2308
+ def _write_streaming_unit_page(
2309
+ self,
2310
+ unit: _UnitRecord,
2311
+ model: SemanticModel,
2312
+ reference_lines: list[str],
2313
+ ) -> None:
2314
+ symbols = sorted(
2315
+ _iter_symbols(model.unit_scope, include_unit=False),
2316
+ key=lambda symbol: (
2317
+ symbol.decl_range.start_line,
2318
+ symbol.decl_range.start_col,
2319
+ symbol.name.casefold(),
2320
+ symbol.kind.value,
2321
+ ),
2322
+ )
2323
+ projects = self._project_memberships_for(unit.source_path)
2324
+ body = [
2325
+ f"# {_escape(unit.name)}",
2326
+ "",
2327
+ f"- Source: `{_escape_code(self._display_path(unit.source_path))}`",
2328
+ f"- Symbols: {len(symbols)}",
2329
+ f"- Semantic references: {len(reference_lines)}",
2330
+ "",
2331
+ "## Projects",
2332
+ "",
2333
+ ]
2334
+ body.extend(f"- {_link(title, page)}" for title, page in projects)
2335
+ if not projects:
2336
+ body.append("- No concrete project membership was resolved")
2337
+ body.extend(["", "## Symbols", ""])
2338
+ body.extend(
2339
+ f"- {_link(symbol.name, _symbol_page(symbol))} "
2340
+ f"— `{symbol.kind.value}` at line {symbol.decl_range.start_line}"
2341
+ for symbol in symbols
2342
+ )
2343
+ if not symbols:
2344
+ body.append("- None")
2345
+ body.extend(["", "## References found in this unit", ""])
2346
+ body.extend(reference_lines or ["- None"])
2347
+ metrics_page = self._metric_page_by_source.get(
2348
+ _normalized_source(unit.source_path)
2349
+ )
2350
+ if metrics_page is not None:
2351
+ body.extend(
2352
+ ["", "## Related knowledge", "", f"- {_link('Unit metrics', metrics_page)}"]
2353
+ )
2354
+ self._concept(
2355
+ unit.page,
2356
+ concept_type="Delphi Unit",
2357
+ title=unit.name,
2358
+ description=f"Symbols and references in {unit.name}.",
2359
+ tags=("delphi", "unit"),
2360
+ body=body,
2361
+ )
2362
+
2363
+ def _write_symbol_bases(self, model: SemanticModel) -> None:
2364
+ for symbol in _iter_symbols(model.unit_scope, include_unit=False):
2365
+ page = _symbol_page(symbol)
2366
+ target = self.destination.joinpath(*page.parts)
2367
+ if target.exists():
2368
+ continue
2369
+ page, source, body = self._symbol_body_prefix(symbol)
2370
+ body.extend(["", "## Semantic references", ""])
2371
+ self._concept(
2372
+ page,
2373
+ concept_type=f"Delphi {symbol.kind.value.replace('_', ' ').title()} Symbol",
2374
+ title=symbol.name,
2375
+ description=(
2376
+ f"{symbol.kind.value} {symbol.name} in {source} at "
2377
+ f"line {symbol.decl_range.start_line}."
2378
+ ),
2379
+ tags=("delphi", "symbol", symbol.kind.value),
2380
+ body=body,
2381
+ )
2382
+
2383
+ def _reference_record(
2384
+ self,
2385
+ reference: SymbolReference,
2386
+ *,
2387
+ line: str,
2388
+ model_ordinal: int,
2389
+ reference_ordinal: int,
2390
+ target_key: str | None,
2391
+ ) -> dict[str, Any]:
2392
+ target = reference.resolved
2393
+ source_symbol = (
2394
+ reference.scope.owner.name
2395
+ if reference.scope.owner is not None
2396
+ else reference.scope.name
2397
+ )
2398
+ reference_key = (
2399
+ _normalized_source(reference.ref_range.file_name),
2400
+ reference.ref_range.start_line,
2401
+ reference.ref_range.start_col,
2402
+ reference.kind.value,
2403
+ reference.name.casefold(),
2404
+ )
2405
+ dedupe_key = "|".join(
2406
+ (
2407
+ reference.name.casefold(),
2408
+ reference.ref_range.file_name,
2409
+ str(reference.ref_range.start_line),
2410
+ str(reference.ref_range.start_col),
2411
+ reference.kind.value,
2412
+ )
2413
+ )
2414
+ order_key = "|".join(
2415
+ (
2416
+ f"{model_ordinal:012d}",
2417
+ reference_key[0],
2418
+ f"{reference_key[1]:012d}",
2419
+ f"{reference_key[2]:012d}",
2420
+ reference_key[3],
2421
+ reference_key[4],
2422
+ f"{reference_ordinal:012d}",
2423
+ )
2424
+ )
2425
+ return {
2426
+ "column": reference.ref_range.start_col,
2427
+ "dedupe_key": dedupe_key,
2428
+ "file": reference.ref_range.file_name,
2429
+ "file_key": reference_key[0],
2430
+ "kind": reference.kind.value,
2431
+ "line": line,
2432
+ "line_number": reference.ref_range.start_line,
2433
+ "model_ordinal": f"{model_ordinal:012d}",
2434
+ "name": reference.name,
2435
+ "name_key": reference.name.casefold(),
2436
+ "order_key": order_key,
2437
+ "reference_ordinal": f"{reference_ordinal:012d}",
2438
+ "resolved": target is not None,
2439
+ "source_symbol": source_symbol,
2440
+ "target_key": target_key,
2441
+ }
2442
+
2443
+ def _sort_references(self) -> Path:
2444
+ sorted_references = self.workspace / "references-sorted.jsonl"
2445
+ sort_jsonl_records(
2446
+ self.raw_references,
2447
+ sorted_references,
2448
+ key=lambda item: (
2449
+ str(item["target_key"]),
2450
+ str(item["file_key"]),
2451
+ f"{int(item['line_number']):012d}",
2452
+ f"{int(item['column']):012d}",
2453
+ str(item["kind"]),
2454
+ str(item["name_key"]),
2455
+ str(item["model_ordinal"]),
2456
+ str(item["reference_ordinal"]),
2457
+ ),
2458
+ max_records_per_run=self.strategy.sort_records_per_run,
2459
+ max_bytes_per_run=self.strategy.sort_bytes_per_run,
2460
+ temp_dir=self.workspace,
2461
+ heartbeat=lambda: self.guard.check(
2462
+ phase="external reference sort",
2463
+ ),
2464
+ force_heartbeat=lambda: self.guard.check(
2465
+ phase="external reference sort",
2466
+ force_process_tree=True,
2467
+ ),
2468
+ )
2469
+ self.guard.check(phase="external reference sort", force_process_tree=True)
2470
+ return sorted_references
2471
+
2472
+ def _finish_symbol_pages_and_references(self, sorted_references: Path) -> None:
2473
+ resolved_index = self.workspace / "resolved-index-raw.jsonl"
2474
+ resolved_index.touch()
2475
+ references = _iter_json_objects(sorted_references)
2476
+ current_reference = next(references, None)
2477
+ symbol_total = self.symbol_count
2478
+ with resolved_index.open("a", encoding="utf-8", newline="\n") as resolved_stream:
2479
+ for ordinal, symbol_item in enumerate(
2480
+ _iter_json_objects(self.unique_catalog),
2481
+ start=1,
2482
+ ):
2483
+ identity = str(symbol_item["identity"])
2484
+ page = PurePosixPath(str(symbol_item["page"]))
2485
+ page_path = self.destination.joinpath(*page.parts)
2486
+ count = 0
2487
+ with page_path.open("a", encoding="utf-8", newline="\n") as page_stream:
2488
+ page_stream.write("\n")
2489
+ while (
2490
+ current_reference is not None
2491
+ and str(current_reference["target_key"]) < identity
2492
+ ):
2493
+ current_reference = next(references, None)
2494
+ while (
2495
+ current_reference is not None
2496
+ and str(current_reference["target_key"]) == identity
2497
+ ):
2498
+ page_stream.write(str(current_reference["line"]))
2499
+ page_stream.write("\n")
2500
+ count += 1
2501
+ current_reference = next(references, None)
2502
+ if count == 0:
2503
+ page_stream.write("- None\n")
2504
+ if count:
2505
+ resolved_item = {
2506
+ "count": count,
2507
+ "kind": symbol_item["kind"],
2508
+ "name": symbol_item["name"],
2509
+ "name_key": symbol_item["name_key"],
2510
+ "page": symbol_item["page"],
2511
+ "source_key": symbol_item["source_key"],
2512
+ "start_col": symbol_item["start_col"],
2513
+ "start_line": symbol_item["start_line"],
2514
+ }
2515
+ resolved_stream.write(
2516
+ json.dumps(
2517
+ resolved_item,
2518
+ ensure_ascii=False,
2519
+ separators=(",", ":"),
2520
+ )
2521
+ )
2522
+ resolved_stream.write("\n")
2523
+ self._progress(
2524
+ "symbols",
2525
+ ordinal,
2526
+ symbol_total,
2527
+ str(symbol_item["source_key"]),
2528
+ f"symbol page finalized: {symbol_item['name']}",
2529
+ )
2530
+ if ordinal % 1_000 == 0:
2531
+ self.guard.check(phase="reverse references")
2532
+ self.guard.check(phase="reverse references", force_process_tree=True)
2533
+ self._write_symbol_index()
2534
+ unresolved_count = self._write_unresolved_page()
2535
+ resolved_count = self._write_reference_index(resolved_index)
2536
+ self._progress(
2537
+ "references",
2538
+ 1,
2539
+ 1,
2540
+ "references/unresolved.md",
2541
+ (
2542
+ f"reference pages written ({resolved_count} resolved "
2543
+ f"targets, {unresolved_count} unresolved)"
2544
+ ),
2545
+ )
2546
+
2547
+ def _write_symbol_index(self) -> None:
2548
+ self._directory_index_iter(
2549
+ PurePosixPath("symbols/index.md"),
2550
+ "Symbols",
2551
+ (
2552
+ (
2553
+ f"{item['name']} ({item['kind']})",
2554
+ PurePosixPath(str(item["page"])),
2555
+ )
2556
+ for item in _iter_json_objects(self.name_catalog)
2557
+ ),
2558
+ empty="No non-unit symbols were indexed.",
2559
+ )
2560
+
2561
+ def _write_unresolved_page(self) -> int:
2562
+ by_key = self.workspace / "unresolved-by-key.jsonl"
2563
+ unique = self.workspace / "unresolved-unique.jsonl"
2564
+ ordered = self.workspace / "unresolved-ordered.jsonl"
2565
+ sort_jsonl_records(
2566
+ self.raw_unresolved,
2567
+ by_key,
2568
+ key=lambda item: (str(item["dedupe_key"]), str(item["order_key"])),
2569
+ max_records_per_run=self.strategy.sort_records_per_run,
2570
+ max_bytes_per_run=self.strategy.sort_bytes_per_run,
2571
+ temp_dir=self.workspace,
2572
+ heartbeat=lambda: self.guard.check(
2573
+ phase="unresolved reference sort",
2574
+ ),
2575
+ force_heartbeat=lambda: self.guard.check(
2576
+ phase="unresolved reference sort",
2577
+ force_process_tree=True,
2578
+ ),
2579
+ )
2580
+ previous_key: str | None = None
2581
+ unresolved_count = 0
2582
+ with unique.open("w", encoding="utf-8", newline="\n") as target:
2583
+ for item in _iter_json_objects(by_key):
2584
+ dedupe_key = str(item["dedupe_key"])
2585
+ if dedupe_key == previous_key:
2586
+ continue
2587
+ previous_key = dedupe_key
2588
+ target.write(json.dumps(item, ensure_ascii=False, separators=(",", ":")))
2589
+ target.write("\n")
2590
+ unresolved_count += 1
2591
+ sort_jsonl_records(
2592
+ unique,
2593
+ ordered,
2594
+ fields=("order_key",),
2595
+ max_records_per_run=self.strategy.sort_records_per_run,
2596
+ max_bytes_per_run=self.strategy.sort_bytes_per_run,
2597
+ temp_dir=self.workspace,
2598
+ heartbeat=lambda: self.guard.check(
2599
+ phase="unresolved reference sort",
2600
+ ),
2601
+ force_heartbeat=lambda: self.guard.check(
2602
+ phase="unresolved reference sort",
2603
+ force_process_tree=True,
2604
+ ),
2605
+ )
2606
+
2607
+ def body() -> Iterator[str]:
2608
+ yield "# Unresolved semantic references"
2609
+ yield ""
2610
+ yield (
2611
+ "These names were observed by the tolerant semantic index but "
2612
+ "could not be resolved soundly in the current workspace."
2613
+ )
2614
+ yield ""
2615
+ if unresolved_count:
2616
+ for item in _iter_json_objects(ordered):
2617
+ yield str(item["line"])
2618
+ else:
2619
+ yield "- None"
2620
+
2621
+ self._concept_iter(
2622
+ PurePosixPath("references/unresolved.md"),
2623
+ concept_type="Delphi Unresolved References",
2624
+ title="Unresolved semantic references",
2625
+ description="References that remain unresolved in the tolerant semantic index.",
2626
+ tags=("delphi", "references", "diagnostics"),
2627
+ body=body(),
2628
+ )
2629
+ return unresolved_count
2630
+
2631
+ def _write_reference_index(self, raw_index: Path) -> int:
2632
+ ordered_index = self.workspace / "resolved-index.jsonl"
2633
+ sort_jsonl_records(
2634
+ raw_index,
2635
+ ordered_index,
2636
+ key=lambda item: (
2637
+ str(item["name_key"]),
2638
+ str(item["source_key"]),
2639
+ f"{int(item['start_line']):012d}",
2640
+ f"{int(item['start_col']):012d}",
2641
+ ),
2642
+ max_records_per_run=self.strategy.sort_records_per_run,
2643
+ max_bytes_per_run=self.strategy.sort_bytes_per_run,
2644
+ temp_dir=self.workspace,
2645
+ heartbeat=lambda: self.guard.check(
2646
+ phase="reference index sort",
2647
+ ),
2648
+ force_heartbeat=lambda: self.guard.check(
2649
+ phase="reference index sort",
2650
+ force_process_tree=True,
2651
+ ),
2652
+ )
2653
+ resolved_count = sum(1 for _item in _iter_json_objects(ordered_index))
2654
+
2655
+ def lines() -> Iterator[str]:
2656
+ yield "# References"
2657
+ yield ""
2658
+ yield "Resolved references are embedded on their target symbol pages."
2659
+ yield ""
2660
+ if resolved_count:
2661
+ for item in _iter_json_objects(ordered_index):
2662
+ label = f"{item['name']} ({item['count']})"
2663
+ yield f"- {_link(label, PurePosixPath(str(item['page'])))}"
2664
+ else:
2665
+ yield "- No resolved references"
2666
+ yield ""
2667
+ yield f"- {_link('Unresolved references', PurePosixPath('references/unresolved.md'))}"
2668
+
2669
+ self._write_lines(PurePosixPath("references/index.md"), lines())
2670
+ return resolved_count
2671
+
2672
+ def _write_streaming_problems(self) -> None:
2673
+ payload = layer_payload(self.index, "problems")
2674
+ items = list(payload["items"])
2675
+ for project in self.projects.items():
2676
+ for problem in project["problems"]:
2677
+ items.append(
2678
+ {
2679
+ "kind": problem["kind"],
2680
+ "origin": project["path"],
2681
+ "message": problem["message"],
2682
+ "file": problem["file"],
2683
+ }
2684
+ )
2685
+ page = PurePosixPath("problems/workspace.md")
2686
+ body = ["# Workspace problems", "", f"Problems: {len(items)}", ""]
2687
+ body.extend(
2688
+ f"- **{_escape(str(item['kind']))}:** {_escape(str(item['message']))} "
2689
+ f"(`{_escape_code(self._display_path(str(item.get('origin', ''))))}`)"
2690
+ for item in items
2691
+ )
2692
+ if not items:
2693
+ body.append("- None")
2694
+ body.extend(["", "## Complete problem data", "", _json_block(items)])
2695
+ self._concept(
2696
+ page,
2697
+ concept_type="Delphi Workspace Problems",
2698
+ title="Workspace problems",
2699
+ description="Discovery and deep-project indexing problems.",
2700
+ tags=("delphi", "problems", "diagnostics"),
2701
+ body=body,
2702
+ )
2703
+ self._directory_index(
2704
+ PurePosixPath("problems/index.md"),
2705
+ "Problems",
2706
+ [("Workspace problems", page)],
2707
+ empty="No problem report was generated.",
2708
+ )
2709
+
2710
+
2711
+ def _iter_json_objects(path: Path) -> Iterator[dict[str, Any]]:
2712
+ with path.open("r", encoding="utf-8") as stream:
2713
+ for line_number, line in enumerate(stream, start=1):
2714
+ if not line.strip():
2715
+ continue
2716
+ item = json.loads(line)
2717
+ if not isinstance(item, dict):
2718
+ raise WikiExportError(
2719
+ f"Invalid wiki work record in {path} at line {line_number}"
2720
+ )
2721
+ yield item
2722
+
2723
+
2724
+ def _scope_root(scope: Scope) -> Scope:
2725
+ current = scope
2726
+ while current.parent is not None:
2727
+ current = current.parent
2728
+ return current
2729
+
2730
+
2731
+ def _project_item(project: str, result: ProjectIndexResult) -> dict[str, Any]:
2732
+ return {
2733
+ "path": project,
2734
+ "parsed_units": [
2735
+ {"name": unit.name, "path": unit.path, "has_error": unit.has_error}
2736
+ for unit in result.parsed_units
2737
+ ],
2738
+ "include_files": [
2739
+ {"name": item.name, "path": item.path} for item in result.include_files
2740
+ ],
2741
+ "not_found_units": result.not_found_units,
2742
+ "problems": [
2743
+ {
2744
+ "kind": problem.problem_type.value,
2745
+ "file": problem.file_name,
2746
+ "message": problem.description,
2747
+ }
2748
+ for problem in result.problems
2749
+ ],
2750
+ }
2751
+
2752
+
2753
+ def _symbol_identity(symbol: Symbol) -> str:
2754
+ source_range = symbol.decl_range
2755
+ return "|".join(
2756
+ (
2757
+ _normalized_source(source_range.file_name),
2758
+ symbol.name.casefold(),
2759
+ symbol.kind.value,
2760
+ str(source_range.start_line),
2761
+ str(source_range.start_col),
2762
+ str(source_range.end_line),
2763
+ str(source_range.end_col),
2764
+ )
2765
+ )
2766
+
2767
+
2768
+ def _symbol_page(symbol: Symbol) -> PurePosixPath:
2769
+ return PurePosixPath("symbols") / _document_name(
2770
+ symbol.name,
2771
+ _symbol_identity(symbol),
2772
+ )
2773
+
2774
+
2775
+ def _symbol_owner(symbol: Symbol) -> str:
2776
+ return symbol.scope.owner.name if symbol.scope.owner is not None else symbol.scope.name
2777
+
2778
+
2779
+ def _normalized_source(value: str) -> str:
2780
+ absolute = value if os.path.isabs(value) else os.path.abspath(value)
2781
+ return _normalized_absolute_source(absolute)
2782
+
2783
+
2784
+ @lru_cache(maxsize=2_048)
2785
+ def _normalized_absolute_source(value: str) -> str:
2786
+ return os.path.normcase(os.path.normpath(value)).replace("\\", "/")
2787
+
2788
+
2789
+ def _reference_target_key(reference: SymbolReference) -> str | None:
2790
+ target = reference.resolved
2791
+ if target is None or target.kind.value == "unit":
2792
+ return None
2793
+ return _symbol_identity(target)
2794
+
2795
+
2796
+ def _reference_sort_key(reference: SymbolReference) -> tuple[str, int, int, str, str]:
2797
+ return (
2798
+ _normalized_source(reference.ref_range.file_name),
2799
+ reference.ref_range.start_line,
2800
+ reference.ref_range.start_col,
2801
+ reference.kind.value,
2802
+ reference.name.casefold(),
2803
+ )
2804
+
2805
+
2806
+ @lru_cache(maxsize=2_048)
2807
+ def _document_name(label: str, identity: str) -> str:
2808
+ normalized = unicodedata.normalize("NFKD", label)
2809
+ ascii_label = normalized.encode("ascii", "ignore").decode("ascii").casefold()
2810
+ slug = re.sub(r"[^a-z0-9]+", "-", ascii_label).strip("-")[:64] or "concept"
2811
+ digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16]
2812
+ return f"{slug}-{digest}.md"
2813
+
2814
+
2815
+ def _link(label: str, target: PurePosixPath) -> str:
2816
+ return f"[{_escape(label)}](/{target.as_posix()})"
2817
+
2818
+
2819
+ def _escape(value: str) -> str:
2820
+ return re.sub(r"([\\`*_[\]<>])", r"\\\1", value)
2821
+
2822
+
2823
+ def _escape_code(value: str) -> str:
2824
+ return value.replace("`", "\\`")
2825
+
2826
+
2827
+ def _bullet_code_values(values: Iterable[Any]) -> list[str]:
2828
+ rendered = [f"- `{_escape_code(str(value))}`" for value in values]
2829
+ return rendered or ["- None"]
2830
+
2831
+
2832
+ def _inline_code(values: Iterable[str]) -> str:
2833
+ return ", ".join(f"`{_escape_code(value)}`" for value in values)
2834
+
2835
+
2836
+ def _json_block(value: Any) -> str:
2837
+ return _code_block(json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True), "json")
2838
+
2839
+
2840
+ def _code_block(value: str, language: str) -> str:
2841
+ longest = max((len(match.group(0)) for match in re.finditer(r"`+", value)), default=0)
2842
+ fence = "`" * max(3, longest + 1)
2843
+ return f"{fence}{language}\n{value}\n{fence}"
2844
+
2845
+
2846
+ __all__ = [
2847
+ "OKF_VERSION",
2848
+ "WikiExportError",
2849
+ "WikiExportResult",
2850
+ "WikiProgressCallback",
2851
+ "WikiProgressEvent",
2852
+ "export_okf_wiki",
2853
+ ]