Guard long-running adapter implementations
This commit is contained in:
parent
bb13258861
commit
1ef76f0271
11 changed files with 541 additions and 11 deletions
|
|
@ -1,13 +1,13 @@
|
||||||
# Active slice
|
# Active slice
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Slice: DFG-22 deterministic incremental adapter assembly
|
Slice: DFG-23 process-stable adapter implementation boundary
|
||||||
Goal: Let language frontends cache repeated raw source evidence while publishing one deterministic graph without maintaining a second project-owned extraction cache.
|
Goal: Prevent a long-running project server from synchronizing with adapter code or configuration that changed after the adapter object was imported.
|
||||||
In scope: Optional generic assembly contract; cached contribution assembly; manifest-bound identity checks; final Logic ownership checks; overlapping-evidence tests; incremental documentation and onboarding guidance.
|
In scope: Language-neutral implementation roots/files/suffixes; project-local Python package inference; descriptor fingerprinting; bounded change evidence; restart-required MCP remediation; staged/unstaged deletion contract guidance.
|
||||||
Out of scope: Language-specific merge rules; bundled C++, Rust, Java, or other frontends; changes to the default unique-ownership path; hidden source reads during assembly; project-owned cache formats; source mutation; deployment or publication.
|
Out of scope: In-process Python module reloading; MCP self-restart; project-specific Git enumeration; canonical source mutation; deployment or publication.
|
||||||
Done when: An adapter can cache overlapping source contributions, deterministically assemble one valid node, reuse the warm cache, reparse only one changed source, preserve the selected published fact, pass full/incremental equivalence, and the complete DocForge quality gate passes.
|
Done when: Every MCP operation rejects added, changed, deleted, missing, or unsafe adapter implementation files before synchronization; derived files outside the declared boundary remain ignored; descriptor changes require restart; deletion semantics remain manifest-owned and staging-independent; the complete DocForge quality gate passes.
|
||||||
Owners: DocForge owns cached contribution delivery, the optional assembly boundary, identity validation, and final graph validation. The adapter owns deterministic evidence selection and merge semantics. Canonical source files remain authoritative.
|
Owners: DocForge owns implementation-boundary confinement, fingerprinting, bounded diagnostics, MCP preflight, and restart remediation. Adapters own the declared boundary and current source-manifest enumeration. Canonical sources and Git staging remain outside this lifecycle guard.
|
||||||
Proof: The focused adapter and onboarding suite passed 14 tests. Strict Pyright passed with no errors or warnings. Ruff lint and formatting, Python compilation, and the HTML/CSS/JavaScript quality gate passed. The complete warning-strict suite passed 82 tests and 2 subtests. The overlap fixture cached two source contributions, published one deterministic node, reused both warm entries, reparsed only one changed source, preserved the selected node, and passed full/incremental equivalence.
|
Proof: The focused adapter suite passed 15 tests, including inferred and explicit implementation boundaries, descriptor changes, additions, edits, deletions, ignored derived files, and exact MCP remediation. Strict Pyright passed with no errors or warnings. Ruff lint and formatting, Python compilation, and the HTML/CSS/JavaScript quality gate passed. The complete warning-strict suite passed 87 tests and 2 subtests.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Next gate:** Prove Worldforge's C++ integration against the generic incremental and assembly
|
**Next gate:** Prove Worldforge's C++ integration against the generic incremental and assembly
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ declared manuals, visualizes project structure, and manages reviewable documenta
|
||||||
- Applies one explicitly approved changeset hash through CLI or gated MCP.
|
- Applies one explicitly approved changeset hash through CLI or gated MCP.
|
||||||
- Supports opt-in incremental adapters with reverse-dependency invalidation and full-build
|
- Supports opt-in incremental adapters with reverse-dependency invalidation and full-build
|
||||||
equivalence checks.
|
equivalence checks.
|
||||||
|
- Detects project-local adapter implementation and configuration changes and requires a fresh
|
||||||
|
project-bound process before any further MCP work.
|
||||||
- Keeps function-scoped control-flow projections separate from the primary architecture graph.
|
- Keeps function-scoped control-flow projections separate from the primary architecture graph.
|
||||||
- Runs a managed loopback graph browser with neighborhood, semantic Flow, convergence Web,
|
- Runs a managed loopback graph browser with neighborhood, semantic Flow, convergence Web,
|
||||||
function-scoped Logic, source inspection, and branch-aware node hiding.
|
function-scoped Logic, source inspection, and branch-aware node hiding.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,29 @@
|
||||||
# Completed slices
|
# Completed slices
|
||||||
|
|
||||||
|
## DFG-23 process-stable adapter implementation boundary
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added a confined implementation boundary with explicit roots, files, and suffixes.
|
||||||
|
- Inferred the project-local Python package containing a loader and included its declared
|
||||||
|
descriptor without requiring every existing adapter to opt in manually.
|
||||||
|
- Fingerprinted relative implementation paths and bytes at process start under fixed file and byte
|
||||||
|
limits.
|
||||||
|
- Rejected edits, additions, deletions, missing files, and unsafe replacements with
|
||||||
|
`adapter_restart_required` before MCP synchronization or proposal validation.
|
||||||
|
- Returned bounded change evidence and explicit non-retryable `restart_project_server`
|
||||||
|
remediation.
|
||||||
|
- Clarified that current source manifests must treat staged and unstaged deletions identically;
|
||||||
|
Git staging is not a synchronization operation.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- Focused tests cover inferred and explicit implementation boundaries, descriptor changes, added,
|
||||||
|
edited, and deleted files, ignored derived files, and exact MCP remediation.
|
||||||
|
- Strict Pyright, Ruff lint and formatting, Python compilation, and the HTML/CSS/JavaScript quality
|
||||||
|
gate passed.
|
||||||
|
- The complete warning-strict suite passed 87 tests and 2 subtests.
|
||||||
|
|
||||||
## DFG-22 deterministic incremental adapter assembly
|
## DFG-22 deterministic incremental adapter assembly
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,34 @@ The incremental path is:
|
||||||
Missing, incompatible, or corrupt cache data is a cache miss. It must never become a partial graph
|
Missing, incompatible, or corrupt cache data is a cache miss. It must never become a partial graph
|
||||||
or replace the last valid index.
|
or replace the last valid index.
|
||||||
|
|
||||||
|
### Declare the process-stable adapter implementation boundary
|
||||||
|
|
||||||
|
An adapter object is loaded once when its project-bound process starts. Source synchronization can
|
||||||
|
refresh the graph, but it cannot safely replace already imported adapter code in place.
|
||||||
|
|
||||||
|
`AdapterProject` automatically fingerprints a project-local Python package containing the loader
|
||||||
|
class and a declared descriptor file. Declare a broader or non-Python boundary explicitly when the
|
||||||
|
adapter uses helpers, configuration, schemas, or templates outside that inferred package:
|
||||||
|
|
||||||
|
```python
|
||||||
|
settings = AdapterProjectSettings(
|
||||||
|
implementation=AdapterImplementation(
|
||||||
|
roots=(project_root / "docforge_adapter",),
|
||||||
|
files=(project_root / ".docforge" / "project.toml",),
|
||||||
|
suffixes=(".py", ".toml"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The boundary is confined to the project root and limited to 4,096 files and 64,000,000 bytes.
|
||||||
|
DocForge fingerprints relative paths and bytes. An added, changed, deleted, missing, or symlinked
|
||||||
|
implementation file produces `adapter_restart_required` before another MCP operation. Restart the
|
||||||
|
project-bound server; do not use Python module reloading to mutate a live adapter graph.
|
||||||
|
|
||||||
|
The manifest remains a current source snapshot, not a Git-index snapshot. A Git-backed adapter must
|
||||||
|
omit a deleted source whether its deletion is unstaged or staged. Staging is never a required
|
||||||
|
DocForge synchronization step.
|
||||||
|
|
||||||
## Step 9: keep the complete path independent
|
## Step 9: keep the complete path independent
|
||||||
|
|
||||||
The full rebuild must not read the incremental extraction cache. Otherwise equivalence compares
|
The full rebuild must not read the incremental extraction cache. Otherwise equivalence compares
|
||||||
|
|
@ -344,7 +372,9 @@ An adapter is not complete until these cases pass:
|
||||||
| Shared header/module change | Every reverse dependent reparses |
|
| Shared header/module change | Every reverse dependent reparses |
|
||||||
| Added source | New contribution appears without stale duplicates |
|
| Added source | New contribution appears without stale duplicates |
|
||||||
| Renamed source | Old contribution disappears and new identity follows policy |
|
| Renamed source | Old contribution disappears and new identity follows policy |
|
||||||
| Deleted source | Owned nodes, relationships, and Logic disappear |
|
| Unstaged and staged deleted source | Owned nodes, relationships, and Logic disappear identically |
|
||||||
|
| Adapter implementation edit/add/delete | `adapter_restart_required` before synchronization |
|
||||||
|
| Adapter descriptor/configuration edit | `adapter_restart_required` before synchronization |
|
||||||
| Build flags/features change | Affected units invalidate |
|
| Build flags/features change | Affected units invalidate |
|
||||||
| Extractor version change | Old contributions invalidate |
|
| Extractor version change | Old contributions invalidate |
|
||||||
| Corrupt cache | Clean recovery without partial publication |
|
| Corrupt cache | Clean recovery without partial publication |
|
||||||
|
|
@ -445,4 +475,3 @@ For every such change:
|
||||||
- [ ] Session composition and family isolation are proven.
|
- [ ] Session composition and family isolation are proven.
|
||||||
- [ ] Viewer, query, context, and Logic retrieval are proven.
|
- [ ] Viewer, query, context, and Logic retrieval are proven.
|
||||||
- [ ] Performance, graph shape, unsupported facts, and version rules are recorded.
|
- [ ] Performance, graph shape, unsupported facts, and version rules are recorded.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,10 +96,27 @@ DocForge invalidates a source when:
|
||||||
Deleted sources are omitted from the candidate projection. Their cached dependency declarations
|
Deleted sources are omitted from the candidate projection. Their cached dependency declarations
|
||||||
remain available long enough to invalidate surviving dependents.
|
remain available long enough to invalidate surviving dependents.
|
||||||
|
|
||||||
|
The manifest describes the current supported filesystem snapshot. It must not retain a missing file
|
||||||
|
only because Git still tracks it, and it must not require staging before a deletion disappears.
|
||||||
|
Git-backed adapters must prove that staged and unstaged deletions produce the same current source
|
||||||
|
set. DocForge then removes the omitted contribution and invalidates its surviving reverse
|
||||||
|
dependents.
|
||||||
|
|
||||||
If an adapter cannot precisely describe the affected sources, it should declare broader
|
If an adapter cannot precisely describe the affected sources, it should declare broader
|
||||||
dependencies or change its adapter/extractor version. Incorrectly retaining a stale relationship
|
dependencies or change its adapter/extractor version. Incorrectly retaining a stale relationship
|
||||||
is never an acceptable optimization.
|
is never an acceptable optimization.
|
||||||
|
|
||||||
|
## Adapter implementation lifecycle
|
||||||
|
|
||||||
|
Graph source changes are synchronizable. Changes to the code or configuration implementing the
|
||||||
|
adapter are not.
|
||||||
|
|
||||||
|
`AdapterProject` fingerprints the inferred or explicitly declared implementation boundary when the
|
||||||
|
project process starts. Every MCP operation validates that boundary before work begins. Changes to
|
||||||
|
implementation paths or bytes return `adapter_restart_required` with bounded added, changed, and
|
||||||
|
deleted path evidence. The error is intentionally not auto-repaired through `docforge_sync`; a
|
||||||
|
fresh process must import and validate the current adapter.
|
||||||
|
|
||||||
## Build reporting
|
## Build reporting
|
||||||
|
|
||||||
`build` and `reindex` include an extraction report:
|
`build` and `reindex` include an extraction report:
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ returns the complete fixed binding, active index path, proposal and application
|
||||||
recommended workflow. `docforge_sync` exposes the same idempotent synchronization explicitly.
|
recommended workflow. `docforge_sync` exposes the same idempotent synchronization explicitly.
|
||||||
Neither operation changes canonical sources.
|
Neither operation changes canonical sources.
|
||||||
|
|
||||||
|
Adapter-backed servers also validate their process-start implementation fingerprint before every
|
||||||
|
tool. `adapter_restart_required` is stale but not synchronizable. Its remediation is
|
||||||
|
`restart_project_server`; the current process does not reload project code, update Git staging, or
|
||||||
|
continue with a newly changed validator.
|
||||||
|
|
||||||
The normal command binds the generic project loader. An explicit project integration may instead
|
The normal command binds the generic project loader. An explicit project integration may instead
|
||||||
construct the same read-only surface from a validated `ProjectService` and project-owned context
|
construct the same read-only surface from a validated `ProjectService` and project-owned context
|
||||||
provider. This form cannot register proposal tools. Project discovery, session selection, family
|
provider. This form cannot register proposal tools. Project discovery, session selection, family
|
||||||
|
|
|
||||||
|
|
@ -658,6 +658,16 @@ invalidation rules, manual-application lifecycle, and lazy Logic boundary.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `adapter_restart_required`
|
||||||
|
|
||||||
|
The project-local adapter code, its declared descriptor, or another implementation file changed
|
||||||
|
after the project-bound MCP process started. DocForge rejects every further operation before
|
||||||
|
synchronization because the live Python objects still represent the prior implementation.
|
||||||
|
|
||||||
|
Restart the MCP server or start a fresh client session. Do not stage files merely to change the
|
||||||
|
adapter's source manifest, and do not attempt in-process module reloading. The error includes
|
||||||
|
bounded added, changed, and deleted path evidence to identify the changed implementation boundary.
|
||||||
|
|
||||||
### `stale_index` or `visualization_stale`
|
### `stale_index` or `visualization_stale`
|
||||||
|
|
||||||
Normal MCP operations automatically repair a missing, stale, or invalid disposable index under a
|
Normal MCP operations automatically repair a missing, stale, or invalid disposable index under a
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
@ -179,6 +180,18 @@ ProposalValidator = Callable[
|
||||||
],
|
],
|
||||||
None,
|
None,
|
||||||
]
|
]
|
||||||
|
MAX_IMPLEMENTATION_DIFF_PATHS = 50
|
||||||
|
MAX_IMPLEMENTATION_FILES = 4_096
|
||||||
|
MAX_IMPLEMENTATION_BYTES = 64_000_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AdapterImplementation:
|
||||||
|
"""One confined implementation boundary that must remain stable for a process."""
|
||||||
|
|
||||||
|
roots: tuple[Path, ...] = ()
|
||||||
|
files: tuple[Path, ...] = ()
|
||||||
|
suffixes: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -194,6 +207,13 @@ class AdapterProjectSettings:
|
||||||
render: RenderConfig | None = None
|
render: RenderConfig | None = None
|
||||||
limits: Limits | None = None
|
limits: Limits | None = None
|
||||||
proposal_validator: ProposalValidator | None = None
|
proposal_validator: ProposalValidator | None = None
|
||||||
|
implementation: AdapterImplementation | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ImplementationSnapshot:
|
||||||
|
fingerprint: str
|
||||||
|
files: tuple[tuple[str, str], ...]
|
||||||
|
|
||||||
|
|
||||||
class AdapterProject:
|
class AdapterProject:
|
||||||
|
|
@ -262,6 +282,12 @@ class AdapterProject:
|
||||||
adapter_version,
|
adapter_version,
|
||||||
root,
|
root,
|
||||||
)
|
)
|
||||||
|
self._implementation = self._validate_implementation(
|
||||||
|
root,
|
||||||
|
loader,
|
||||||
|
self.settings.implementation,
|
||||||
|
descriptor_path=self.settings.descriptor_path,
|
||||||
|
)
|
||||||
self._cache_path = resolved_cache / "extractions.json"
|
self._cache_path = resolved_cache / "extractions.json"
|
||||||
self._last_build_report: dict[str, object] = {
|
self._last_build_report: dict[str, object] = {
|
||||||
"mode": "full",
|
"mode": "full",
|
||||||
|
|
@ -341,8 +367,10 @@ class AdapterProject:
|
||||||
limits=limits,
|
limits=limits,
|
||||||
)
|
)
|
||||||
self._canonical_sources = canonical_sources
|
self._canonical_sources = canonical_sources
|
||||||
|
self._implementation_snapshot = self._capture_implementation(initial=True)
|
||||||
|
|
||||||
def load(self) -> ProjectSnapshot:
|
def load(self) -> ProjectSnapshot:
|
||||||
|
self.validate_runtime()
|
||||||
canonical_sources = self.canonical_source_paths()
|
canonical_sources = self.canonical_source_paths()
|
||||||
captured = {path: path.read_bytes() for path in canonical_sources}
|
captured = {path: path.read_bytes() for path in canonical_sources}
|
||||||
projection = (
|
projection = (
|
||||||
|
|
@ -359,6 +387,7 @@ class AdapterProject:
|
||||||
)
|
)
|
||||||
if identity != self._identity:
|
if identity != self._identity:
|
||||||
raise DocForgeError("adapter_changed", "Adapter identity changed during the operation")
|
raise DocForgeError("adapter_changed", "Adapter identity changed during the operation")
|
||||||
|
self.validate_runtime()
|
||||||
if self.canonical_source_paths() != canonical_sources or any(
|
if self.canonical_source_paths() != canonical_sources or any(
|
||||||
not path.is_file() or path.read_bytes() != raw for path, raw in captured.items()
|
not path.is_file() or path.read_bytes() != raw for path, raw in captured.items()
|
||||||
):
|
):
|
||||||
|
|
@ -382,6 +411,7 @@ class AdapterProject:
|
||||||
def incremental_state(self) -> ProjectState | None:
|
def incremental_state(self) -> ProjectState | None:
|
||||||
"""Return current source identity without reconstructing the complete projection."""
|
"""Return current source identity without reconstructing the complete projection."""
|
||||||
|
|
||||||
|
self.validate_runtime()
|
||||||
loader = self._incremental_loader
|
loader = self._incremental_loader
|
||||||
if loader is None:
|
if loader is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -391,6 +421,7 @@ class AdapterProject:
|
||||||
validate_manifest(manifest)
|
validate_manifest(manifest)
|
||||||
if manifest.identity() != self._identity:
|
if manifest.identity() != self._identity:
|
||||||
raise DocForgeError("adapter_changed", "Adapter identity changed during the operation")
|
raise DocForgeError("adapter_changed", "Adapter identity changed during the operation")
|
||||||
|
self.validate_runtime()
|
||||||
if self.canonical_source_paths() != canonical_sources or any(
|
if self.canonical_source_paths() != canonical_sources or any(
|
||||||
not path.is_file() or path.read_bytes() != raw for path, raw in captured.items()
|
not path.is_file() or path.read_bytes() != raw for path, raw in captured.items()
|
||||||
):
|
):
|
||||||
|
|
@ -412,6 +443,45 @@ class AdapterProject:
|
||||||
|
|
||||||
return dict(self._last_build_report)
|
return dict(self._last_build_report)
|
||||||
|
|
||||||
|
def validate_runtime(self) -> None:
|
||||||
|
"""Reject use after declared adapter implementation files change."""
|
||||||
|
|
||||||
|
expected = self._implementation_snapshot
|
||||||
|
if expected is None:
|
||||||
|
return
|
||||||
|
current = self._capture_implementation()
|
||||||
|
if current is None:
|
||||||
|
raise DocForgeError(
|
||||||
|
"adapter_restart_required",
|
||||||
|
"Adapter implementation policy changed after the project server started",
|
||||||
|
)
|
||||||
|
if current == expected:
|
||||||
|
return
|
||||||
|
expected_files = dict(expected.files)
|
||||||
|
current_files = dict(current.files)
|
||||||
|
added = sorted(set(current_files) - set(expected_files))
|
||||||
|
deleted = sorted(set(expected_files) - set(current_files))
|
||||||
|
changed = sorted(
|
||||||
|
path
|
||||||
|
for path in set(expected_files) & set(current_files)
|
||||||
|
if expected_files[path] != current_files[path]
|
||||||
|
)
|
||||||
|
raise DocForgeError(
|
||||||
|
"adapter_restart_required",
|
||||||
|
"Adapter implementation changed after the project server started",
|
||||||
|
started_fingerprint=expected.fingerprint,
|
||||||
|
current_fingerprint=current.fingerprint,
|
||||||
|
added_count=len(added),
|
||||||
|
deleted_count=len(deleted),
|
||||||
|
changed_count=len(changed),
|
||||||
|
added=added[:MAX_IMPLEMENTATION_DIFF_PATHS],
|
||||||
|
deleted=deleted[:MAX_IMPLEMENTATION_DIFF_PATHS],
|
||||||
|
changed=changed[:MAX_IMPLEMENTATION_DIFF_PATHS],
|
||||||
|
paths_truncated=any(
|
||||||
|
len(paths) > MAX_IMPLEMENTATION_DIFF_PATHS for paths in (added, deleted, changed)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def logic_projection(self, owner_node_id: str) -> LogicProjection | None:
|
def logic_projection(self, owner_node_id: str) -> LogicProjection | None:
|
||||||
"""Load one lazily stored function-scoped logic projection."""
|
"""Load one lazily stored function-scoped logic projection."""
|
||||||
|
|
||||||
|
|
@ -433,6 +503,7 @@ class AdapterProject:
|
||||||
def verify_incremental_equivalence(self) -> dict[str, object]:
|
def verify_incremental_equivalence(self) -> dict[str, object]:
|
||||||
"""Prove the incremental and full loader contracts produce the same graph."""
|
"""Prove the incremental and full loader contracts produce the same graph."""
|
||||||
|
|
||||||
|
self.validate_runtime()
|
||||||
if self._incremental_loader is None:
|
if self._incremental_loader is None:
|
||||||
raise DocForgeError(
|
raise DocForgeError(
|
||||||
"incremental_disabled", "Adapter does not implement incremental extraction"
|
"incremental_disabled", "Adapter does not implement incremental extraction"
|
||||||
|
|
@ -456,6 +527,7 @@ class AdapterProject:
|
||||||
"Incremental extraction does not match a full adapter projection",
|
"Incremental extraction does not match a full adapter projection",
|
||||||
fields=mismatches,
|
fields=mismatches,
|
||||||
)
|
)
|
||||||
|
self.validate_runtime()
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"project_id": incremental.project_id,
|
"project_id": incremental.project_id,
|
||||||
|
|
@ -656,6 +728,7 @@ class AdapterProject:
|
||||||
projected: ProjectSnapshot,
|
projected: ProjectSnapshot,
|
||||||
operations: tuple[Mapping[str, object], ...],
|
operations: tuple[Mapping[str, object], ...],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self.validate_runtime()
|
||||||
validator = self.settings.proposal_validator
|
validator = self.settings.proposal_validator
|
||||||
if validator is None:
|
if validator is None:
|
||||||
if operations:
|
if operations:
|
||||||
|
|
@ -665,6 +738,179 @@ class AdapterProject:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
validator(base, projected, operations)
|
validator(base, projected, operations)
|
||||||
|
self.validate_runtime()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _validate_implementation(
|
||||||
|
cls,
|
||||||
|
root: Path,
|
||||||
|
loader: AdapterLoader,
|
||||||
|
implementation: AdapterImplementation | None,
|
||||||
|
*,
|
||||||
|
descriptor_path: Path | None,
|
||||||
|
) -> AdapterImplementation | None:
|
||||||
|
if implementation is None:
|
||||||
|
implementation = cls._infer_implementation(root, loader)
|
||||||
|
if descriptor_path is not None and (
|
||||||
|
implementation is None
|
||||||
|
or descriptor_path.resolve(strict=False)
|
||||||
|
not in {path.resolve(strict=False) for path in implementation.files}
|
||||||
|
):
|
||||||
|
implementation = (
|
||||||
|
AdapterImplementation(files=(descriptor_path,))
|
||||||
|
if implementation is None
|
||||||
|
else replace(
|
||||||
|
implementation,
|
||||||
|
files=(*implementation.files, descriptor_path),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if implementation is None:
|
||||||
|
return None
|
||||||
|
roots = cls._resolved_directories(
|
||||||
|
root,
|
||||||
|
implementation.roots,
|
||||||
|
label="implementation root",
|
||||||
|
)
|
||||||
|
files = cls._resolved_files(
|
||||||
|
root,
|
||||||
|
implementation.files,
|
||||||
|
label="implementation file",
|
||||||
|
)
|
||||||
|
suffixes = tuple(sorted(implementation.suffixes))
|
||||||
|
if (
|
||||||
|
not roots
|
||||||
|
and not files
|
||||||
|
or len(suffixes) != len(set(suffixes))
|
||||||
|
or any(
|
||||||
|
not suffix or not suffix.startswith(".") or "/" in suffix or "\\" in suffix
|
||||||
|
for suffix in suffixes
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise DocForgeError(
|
||||||
|
"invalid_adapter",
|
||||||
|
"Adapter implementation policy is invalid",
|
||||||
|
)
|
||||||
|
return AdapterImplementation(roots=roots, files=files, suffixes=suffixes)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_implementation(
|
||||||
|
root: Path,
|
||||||
|
loader: AdapterLoader,
|
||||||
|
) -> AdapterImplementation | None:
|
||||||
|
source = inspect.getsourcefile(type(loader))
|
||||||
|
if source is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
source_path = Path(source).resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not source_path.is_file() or not source_path.is_relative_to(root):
|
||||||
|
return None
|
||||||
|
module = inspect.getmodule(type(loader))
|
||||||
|
package = module.__package__.strip() if module and module.__package__ else ""
|
||||||
|
if package:
|
||||||
|
package_root = source_path.parent
|
||||||
|
for _ in package.split(".")[1:]:
|
||||||
|
package_root = package_root.parent
|
||||||
|
if package_root != root and (package_root / "__init__.py").is_file():
|
||||||
|
return AdapterImplementation(roots=(package_root,), suffixes=(".py",))
|
||||||
|
return AdapterImplementation(files=(source_path,))
|
||||||
|
|
||||||
|
def _capture_implementation(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
initial: bool = False,
|
||||||
|
) -> _ImplementationSnapshot | None:
|
||||||
|
implementation = self._implementation
|
||||||
|
if implementation is None:
|
||||||
|
return None
|
||||||
|
root = self.descriptor.root
|
||||||
|
candidates = set(implementation.files)
|
||||||
|
if len(candidates) > MAX_IMPLEMENTATION_FILES:
|
||||||
|
self._raise_implementation_boundary_error(
|
||||||
|
initial,
|
||||||
|
"Adapter implementation boundary exceeds its file limit",
|
||||||
|
max_files=MAX_IMPLEMENTATION_FILES,
|
||||||
|
)
|
||||||
|
for implementation_root in implementation.roots:
|
||||||
|
if (
|
||||||
|
implementation_root.is_symlink()
|
||||||
|
or not implementation_root.is_dir()
|
||||||
|
or not implementation_root.resolve(strict=False).is_relative_to(root)
|
||||||
|
):
|
||||||
|
candidates.add(implementation_root)
|
||||||
|
continue
|
||||||
|
for path in implementation_root.rglob("*"):
|
||||||
|
if path.is_symlink() or (
|
||||||
|
(not implementation.suffixes or path.suffix in implementation.suffixes)
|
||||||
|
and path.is_file()
|
||||||
|
):
|
||||||
|
candidates.add(path)
|
||||||
|
if len(candidates) > MAX_IMPLEMENTATION_FILES:
|
||||||
|
self._raise_implementation_boundary_error(
|
||||||
|
initial,
|
||||||
|
"Adapter implementation boundary exceeds its file limit",
|
||||||
|
max_files=MAX_IMPLEMENTATION_FILES,
|
||||||
|
)
|
||||||
|
captured: list[tuple[str, str]] = []
|
||||||
|
unsafe: list[str] = []
|
||||||
|
total_bytes = 0
|
||||||
|
for path in sorted(candidates):
|
||||||
|
try:
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
if (
|
||||||
|
path.is_symlink()
|
||||||
|
or not path.is_file()
|
||||||
|
or not path.resolve(strict=True).is_relative_to(root)
|
||||||
|
):
|
||||||
|
unsafe.append(relative)
|
||||||
|
continue
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size > MAX_IMPLEMENTATION_BYTES - total_bytes:
|
||||||
|
self._raise_implementation_boundary_error(
|
||||||
|
initial,
|
||||||
|
"Adapter implementation boundary exceeds its byte limit",
|
||||||
|
max_bytes=MAX_IMPLEMENTATION_BYTES,
|
||||||
|
)
|
||||||
|
raw = path.read_bytes()
|
||||||
|
total_bytes += len(raw)
|
||||||
|
if total_bytes > MAX_IMPLEMENTATION_BYTES:
|
||||||
|
self._raise_implementation_boundary_error(
|
||||||
|
initial,
|
||||||
|
"Adapter implementation boundary exceeds its byte limit",
|
||||||
|
max_bytes=MAX_IMPLEMENTATION_BYTES,
|
||||||
|
)
|
||||||
|
captured.append((relative, hashlib.sha256(raw).hexdigest()))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
try:
|
||||||
|
unsafe.append(path.relative_to(root).as_posix())
|
||||||
|
except ValueError:
|
||||||
|
unsafe.append(str(path))
|
||||||
|
if unsafe:
|
||||||
|
self._raise_implementation_boundary_error(
|
||||||
|
initial,
|
||||||
|
"Adapter implementation boundary became missing or unsafe",
|
||||||
|
unsafe=sorted(unsafe),
|
||||||
|
)
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
for relative, content_hash in captured:
|
||||||
|
encoded = relative.encode()
|
||||||
|
digest.update(len(encoded).to_bytes(8, "big"))
|
||||||
|
digest.update(encoded)
|
||||||
|
digest.update(bytes.fromhex(content_hash))
|
||||||
|
return _ImplementationSnapshot(digest.hexdigest(), tuple(captured))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _raise_implementation_boundary_error(
|
||||||
|
initial: bool,
|
||||||
|
message: str,
|
||||||
|
**details: object,
|
||||||
|
) -> None:
|
||||||
|
raise DocForgeError(
|
||||||
|
"invalid_adapter" if initial else "adapter_restart_required",
|
||||||
|
message,
|
||||||
|
**details,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolved_directories(
|
def _resolved_directories(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from .changesets import ChangesetStore
|
||||||
from .context import compile_context
|
from .context import compile_context
|
||||||
from .errors import DocForgeError
|
from .errors import DocForgeError
|
||||||
from .index import ProjectIndex
|
from .index import ProjectIndex
|
||||||
from .models import ProjectService
|
from .models import ProjectService, RuntimeValidatedProject
|
||||||
from .project import Project, project_root_fingerprint
|
from .project import Project, project_root_fingerprint
|
||||||
from .rendering import RenderService
|
from .rendering import RenderService
|
||||||
from .viewer_manager import ViewerManagerClient
|
from .viewer_manager import ViewerManagerClient
|
||||||
|
|
@ -80,6 +80,7 @@ EXCLUDED_OPERATIONS = (
|
||||||
STALE_ERROR_CODES = frozenset(
|
STALE_ERROR_CODES = frozenset(
|
||||||
{
|
{
|
||||||
"base_conflict",
|
"base_conflict",
|
||||||
|
"adapter_restart_required",
|
||||||
"content_conflict",
|
"content_conflict",
|
||||||
"source_changed",
|
"source_changed",
|
||||||
"stale_adapter_source",
|
"stale_adapter_source",
|
||||||
|
|
@ -139,6 +140,8 @@ class DocForgeService:
|
||||||
synchronization: dict[str, object] | None = None
|
synchronization: dict[str, object] | None = None
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
|
if isinstance(self.project, RuntimeValidatedProject):
|
||||||
|
self.project.validate_runtime()
|
||||||
result: dict[str, Any] = operation()
|
result: dict[str, Any] = operation()
|
||||||
except DocForgeError as error:
|
except DocForgeError as error:
|
||||||
if not synchronize or error.code not in RECOVERABLE_INDEX_ERROR_CODES:
|
if not synchronize or error.code not in RECOVERABLE_INDEX_ERROR_CODES:
|
||||||
|
|
@ -202,6 +205,11 @@ class DocForgeService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _remediation(error: DocForgeError) -> dict[str, object] | None:
|
def _remediation(error: DocForgeError) -> dict[str, object] | None:
|
||||||
|
if error.code == "adapter_restart_required":
|
||||||
|
return {
|
||||||
|
"retryable": False,
|
||||||
|
"action": "restart_project_server",
|
||||||
|
}
|
||||||
if error.code in {"missing_index", "stale_index", "invalid_index"}:
|
if error.code in {"missing_index", "stale_index", "invalid_index"}:
|
||||||
return {
|
return {
|
||||||
"retryable": True,
|
"retryable": True,
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,13 @@ class IncrementalStateProject(ProjectService, Protocol):
|
||||||
def incremental_state(self) -> ProjectState | None: ...
|
def incremental_state(self) -> ProjectState | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class RuntimeValidatedProject(ProjectService, Protocol):
|
||||||
|
"""Optional project boundary that proves its loaded implementation is current."""
|
||||||
|
|
||||||
|
def validate_runtime(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class LogicProject(ProjectService, Protocol):
|
class LogicProject(ProjectService, Protocol):
|
||||||
"""Optional project boundary exposing logic from its most recent validated load."""
|
"""Optional project boundary exposing logic from its most recent validated load."""
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import importlib
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
@ -14,6 +16,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
|
||||||
from docforge.adapter_contract import (
|
from docforge.adapter_contract import (
|
||||||
AdapterAssembly,
|
AdapterAssembly,
|
||||||
AdapterEdge,
|
AdapterEdge,
|
||||||
|
AdapterImplementation,
|
||||||
AdapterManifest,
|
AdapterManifest,
|
||||||
AdapterNode,
|
AdapterNode,
|
||||||
AdapterProject,
|
AdapterProject,
|
||||||
|
|
@ -346,6 +349,146 @@ class AdapterContractTests(unittest.TestCase):
|
||||||
with self.assertRaisesRegex(DocForgeError, "confined"):
|
with self.assertRaisesRegex(DocForgeError, "confined"):
|
||||||
AdapterProject(Loader(self.projection(root)), cache_root=outside)
|
AdapterProject(Loader(self.projection(root)), cache_root=outside)
|
||||||
|
|
||||||
|
def test_adapter_implementation_changes_require_a_process_restart(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
implementation_root = root / "adapter"
|
||||||
|
implementation_root.mkdir()
|
||||||
|
implementation = implementation_root / "loader.py"
|
||||||
|
implementation.write_text("VERSION = 1\n", encoding="utf-8")
|
||||||
|
ignored = implementation_root / "loader.pyc"
|
||||||
|
ignored.write_bytes(b"derived")
|
||||||
|
project = AdapterProject(
|
||||||
|
Loader(self.projection(root)),
|
||||||
|
cache_root=root / ".cache" / "shadow",
|
||||||
|
settings=AdapterProjectSettings(
|
||||||
|
implementation=AdapterImplementation(
|
||||||
|
roots=(implementation_root,),
|
||||||
|
suffixes=(".py",),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ProjectIndex(project).build()
|
||||||
|
|
||||||
|
ignored.write_bytes(b"changed derived state")
|
||||||
|
project.validate_runtime()
|
||||||
|
implementation.write_text("VERSION = 2\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with self.assertRaises(DocForgeError) as captured:
|
||||||
|
project.validate_runtime()
|
||||||
|
self.assertEqual("adapter_restart_required", captured.exception.code)
|
||||||
|
self.assertEqual(["adapter/loader.py"], captured.exception.details["changed"])
|
||||||
|
self.assertEqual([], captured.exception.details["added"])
|
||||||
|
self.assertEqual([], captured.exception.details["deleted"])
|
||||||
|
self.assertEqual(1, captured.exception.details["changed_count"])
|
||||||
|
self.assertFalse(captured.exception.details["paths_truncated"])
|
||||||
|
|
||||||
|
def test_adapter_implementation_additions_and_deletions_require_a_restart(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
implementation_root = root / "adapter"
|
||||||
|
implementation_root.mkdir()
|
||||||
|
original = implementation_root / "loader.py"
|
||||||
|
original.write_text("VERSION = 1\n", encoding="utf-8")
|
||||||
|
project = AdapterProject(
|
||||||
|
Loader(self.projection(root)),
|
||||||
|
cache_root=root / ".cache" / "shadow",
|
||||||
|
settings=AdapterProjectSettings(
|
||||||
|
implementation=AdapterImplementation(
|
||||||
|
roots=(implementation_root,),
|
||||||
|
suffixes=(".py",),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
added = implementation_root / "helpers.py"
|
||||||
|
added.write_text("VALUE = 1\n", encoding="utf-8")
|
||||||
|
with self.assertRaises(DocForgeError) as addition:
|
||||||
|
project.load()
|
||||||
|
self.assertEqual("adapter_restart_required", addition.exception.code)
|
||||||
|
self.assertEqual(["adapter/helpers.py"], addition.exception.details["added"])
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
implementation_root = root / "adapter"
|
||||||
|
implementation_root.mkdir()
|
||||||
|
original = implementation_root / "loader.py"
|
||||||
|
original.write_text("VERSION = 1\n", encoding="utf-8")
|
||||||
|
project = AdapterProject(
|
||||||
|
Loader(self.projection(root)),
|
||||||
|
cache_root=root / ".cache" / "shadow",
|
||||||
|
settings=AdapterProjectSettings(
|
||||||
|
implementation=AdapterImplementation(
|
||||||
|
roots=(implementation_root,),
|
||||||
|
suffixes=(".py",),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
original.unlink()
|
||||||
|
with self.assertRaises(DocForgeError) as deletion:
|
||||||
|
project.incremental_state()
|
||||||
|
self.assertEqual("adapter_restart_required", deletion.exception.code)
|
||||||
|
self.assertEqual(["adapter/loader.py"], deletion.exception.details["deleted"])
|
||||||
|
|
||||||
|
def test_adapter_implementation_is_inferred_from_a_project_local_package(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
package = root / "adapter_fixture_dynamic"
|
||||||
|
package.mkdir()
|
||||||
|
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
(package / "loader.py").write_text(
|
||||||
|
"class Loader:\n"
|
||||||
|
" def __init__(self, projection):\n"
|
||||||
|
" self.projection = projection\n"
|
||||||
|
" def load_projection(self):\n"
|
||||||
|
" return self.projection\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
try:
|
||||||
|
module = importlib.import_module("adapter_fixture_dynamic.loader")
|
||||||
|
project = AdapterProject(
|
||||||
|
module.Loader(self.projection(root)),
|
||||||
|
cache_root=root / ".cache" / "shadow",
|
||||||
|
)
|
||||||
|
(package / "helper.py").write_text("VALUE = 1\n", encoding="utf-8")
|
||||||
|
with self.assertRaises(DocForgeError) as captured:
|
||||||
|
project.validate_runtime()
|
||||||
|
finally:
|
||||||
|
sys.path.remove(str(root))
|
||||||
|
sys.modules.pop("adapter_fixture_dynamic.loader", None)
|
||||||
|
sys.modules.pop("adapter_fixture_dynamic", None)
|
||||||
|
|
||||||
|
self.assertEqual("adapter_restart_required", captured.exception.code)
|
||||||
|
self.assertEqual(
|
||||||
|
["adapter_fixture_dynamic/helper.py"],
|
||||||
|
captured.exception.details["added"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_adapter_descriptor_changes_require_a_restart(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
descriptor_root = root / ".docforge"
|
||||||
|
descriptor_root.mkdir()
|
||||||
|
descriptor = descriptor_root / "project.toml"
|
||||||
|
descriptor.write_text("adapter_version = 1\n", encoding="utf-8")
|
||||||
|
project = AdapterProject(
|
||||||
|
Loader(self.projection(root)),
|
||||||
|
cache_root=root / ".cache" / "shadow",
|
||||||
|
settings=AdapterProjectSettings(descriptor_path=descriptor),
|
||||||
|
)
|
||||||
|
|
||||||
|
descriptor.write_text("adapter_version = 2\n", encoding="utf-8")
|
||||||
|
with self.assertRaises(DocForgeError) as captured:
|
||||||
|
project.validate_runtime()
|
||||||
|
|
||||||
|
self.assertEqual("adapter_restart_required", captured.exception.code)
|
||||||
|
self.assertEqual(
|
||||||
|
[".docforge/project.toml"],
|
||||||
|
captured.exception.details["changed"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_incremental_adapter_reuses_sources_and_invalidates_reverse_dependencies(self) -> None:
|
def test_incremental_adapter_reuses_sources_and_invalidates_reverse_dependencies(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory).resolve()
|
root = Path(directory).resolve()
|
||||||
|
|
@ -532,6 +675,45 @@ class AdapterContractTests(unittest.TestCase):
|
||||||
|
|
||||||
|
|
||||||
class AdapterReadOnlyMcpTests(unittest.IsolatedAsyncioTestCase):
|
class AdapterReadOnlyMcpTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_mcp_reports_adapter_restart_remediation_without_synchronizing(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
implementation_root = root / "adapter"
|
||||||
|
implementation_root.mkdir()
|
||||||
|
implementation = implementation_root / "loader.py"
|
||||||
|
implementation.write_text("VERSION = 1\n", encoding="utf-8")
|
||||||
|
fixture = AdapterContractTests()
|
||||||
|
project = AdapterProject(
|
||||||
|
Loader(fixture.projection(root)),
|
||||||
|
cache_root=root / ".cache" / "adapter-read-only",
|
||||||
|
settings=AdapterProjectSettings(
|
||||||
|
implementation=AdapterImplementation(
|
||||||
|
roots=(implementation_root,),
|
||||||
|
suffixes=(".py",),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ProjectIndex(project).build()
|
||||||
|
server = create_read_only_server(project)
|
||||||
|
implementation.write_text("VERSION = 2\n", encoding="utf-8")
|
||||||
|
|
||||||
|
async with create_connected_server_and_client_session(
|
||||||
|
server, raise_exceptions=True
|
||||||
|
) as session:
|
||||||
|
result = await session.call_tool("docforge_project_info", {})
|
||||||
|
|
||||||
|
self.assertEqual("error", result.structuredContent["status"])
|
||||||
|
self.assertEqual(
|
||||||
|
"adapter_restart_required",
|
||||||
|
result.structuredContent["error"]["code"],
|
||||||
|
)
|
||||||
|
self.assertEqual("stale", result.structuredContent["staleness"])
|
||||||
|
self.assertEqual(
|
||||||
|
{"retryable": False, "action": "restart_project_server"},
|
||||||
|
result.structuredContent["error"]["remediation"],
|
||||||
|
)
|
||||||
|
self.assertNotIn("synchronization", result.structuredContent)
|
||||||
|
|
||||||
async def test_adapter_project_exposes_only_read_tools_and_custom_context(self) -> None:
|
async def test_adapter_project_exposes_only_read_tools_and_custom_context(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory).resolve()
|
root = Path(directory).resolve()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue