Skip to content

feat: document cross-references and relationship links#267

Merged
RobertLD merged 1 commit intomainfrom
feat/document-cross-references
Mar 2, 2026
Merged

feat: document cross-references and relationship links#267
RobertLD merged 1 commit intomainfrom
feat/document-cross-references

Conversation

@RobertLD
Copy link
Owner

@RobertLD RobertLD commented Mar 2, 2026

Summary

Adds explicit document-to-document relationships (cross-references) that complement the automatic knowledge graph with human-curated links.

Link Types

Type Meaning
see_also Related reading
prerequisite Must read source before target
supersedes Source replaces target
related Loose association

Changes

Database (src/db/schema.ts)

  • Migration 13: document_links table with UNIQUE(source_id, target_id, link_type), cascade deletes, indexes on source/target

Core (src/core/links.ts) — new module

  • createLink() — with duplicate detection (returns existing on conflict)
  • getDocumentLinks() — returns outgoing + incoming links with titles
  • deleteLink() — with not-found validation
  • getPrerequisiteChain() — walks backwards through prerequisite links with cycle detection
  • listLinks() — all links, optionally filtered by type

MCP Tools (src/mcp/server.ts)

  • link-documents — create relationship between two documents
  • get-document-links — retrieve all links for a document
  • delete-link — remove a link

CLI (src/cli/index.ts)

  • libscope link <sourceId> <targetId> --type see_also --label "..."
  • libscope links <documentId>
  • libscope unlink <linkId>
  • libscope prereqs <documentId>

REST API (src/api/routes.ts)

  • POST /api/v1/documents/:id/links — create link
  • GET /api/v1/documents/:id/links — get links
  • DELETE /api/v1/links/:id — delete link

Tests — 20 new unit tests

  • CRUD operations, duplicate handling
  • Prerequisite chain traversal + cycle detection
  • Cascade delete (source/target document deleted)
  • Validation (self-links, invalid types, missing documents)

Closes #169

Add explicit relationships between documents — see_also, prerequisite,
supersedes, related. Complements automatic knowledge graph with
human-curated links.

New features:
- document_links table (migration 13) with cascade deletes and indexes
- Core module src/core/links.ts: createLink, getDocumentLinks, deleteLink,
  getPrerequisiteChain (with cycle detection), listLinks
- MCP tools: link-documents, get-document-links, delete-link
- CLI commands: link, links, unlink, prereqs
- REST API: POST/GET /api/v1/documents/:id/links, DELETE /api/v1/links/:id
- 20 unit tests covering CRUD, chains, cycles, cascade delete, validation
- CLI reference docs updated

Closes #169

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 2, 2026 19:47
@vercel
Copy link

vercel bot commented Mar 2, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
libscope Ready Ready Preview, Comment Mar 2, 2026 7:47pm

@RobertLD RobertLD merged commit 3151a73 into main Mar 2, 2026
11 of 13 checks passed
@RobertLD RobertLD deleted the feat/document-cross-references branch March 2, 2026 19:51
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class, human-curated document-to-document relationship links (cross-references) on top of the existing knowledge graph, exposing link management via core APIs, REST, MCP tools, CLI, and docs.

Changes:

  • Add schema migration v13 introducing document_links with uniqueness + indexes.
  • Introduce src/core/links.ts with link CRUD, link listing, and prerequisite-chain traversal.
  • Expose link operations through MCP tools, REST routes, CLI commands, and add unit tests + CLI docs.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/db/schema.ts Bumps schema version to 13 and adds document_links migration + indexes.
src/core/links.ts New core module implementing link creation/query/deletion and prerequisite traversal.
src/core/index.ts Re-exports link APIs/types from the core package entrypoint.
src/api/routes.ts Adds REST endpoints to create/list links per document and delete links by ID.
src/mcp/server.ts Adds MCP tools for creating, listing, and deleting document links.
src/cli/index.ts Adds CLI commands to link/unlink/list links and show prerequisite chains.
tests/unit/schema.test.ts Updates expected schema version to 13.
tests/unit/links.test.ts Adds unit tests for link CRUD, duplicate handling, prerequisites, and cascade deletes.
docs/reference/cli.md Documents new CLI commands for document links.

Comment on lines +67 to +72
function assertDocumentExists(db: Database.Database, docId: string, role: string): void {
const row = db.prepare("SELECT id FROM documents WHERE id = ?").get(docId) as
| { id: string }
| undefined;
if (!row) {
throw new DocumentNotFoundError(`${role} document not found: ${docId}`);
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DocumentNotFoundError is being constructed with a full message (e.g. "Source document not found: ") rather than the missing document ID. This produces redundant/awkward messages like "Document not found: Source document not found: …" and diverges from how DocumentNotFoundError is used elsewhere. Pass the raw missing docId to DocumentNotFoundError and use a ValidationError (or a dedicated error type) if you need to communicate whether it was the source or target.

Suggested change
function assertDocumentExists(db: Database.Database, docId: string, role: string): void {
const row = db.prepare("SELECT id FROM documents WHERE id = ?").get(docId) as
| { id: string }
| undefined;
if (!row) {
throw new DocumentNotFoundError(`${role} document not found: ${docId}`);
function assertDocumentExists(db: Database.Database, docId: string, _role: string): void {
const row = db.prepare("SELECT id FROM documents WHERE id = ?").get(docId) as
| { id: string }
| undefined;
if (!row) {
throw new DocumentNotFoundError(docId);

Copilot uses AI. Check for mistakes.
`SELECT l.source_id, d.title
FROM document_links l
JOIN documents d ON d.id = l.source_id
WHERE l.target_id = ? AND l.link_type = 'prerequisite'
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getPrerequisiteChain() selects a single prerequisite with LIMIT 1 but no ORDER BY. If multiple prerequisite links point to the same target, the chosen chain becomes non-deterministic (depends on SQLite query plan/insertion order). Add an explicit ordering (e.g., by created_at) or change the function to handle multiple prerequisites deterministically (or to reject multiple prerequisites).

Suggested change
WHERE l.target_id = ? AND l.link_type = 'prerequisite'
WHERE l.target_id = ? AND l.link_type = 'prerequisite'
ORDER BY l.created_at ASC, l.id ASC

Copilot uses AI. Check for mistakes.
Comment on lines +423 to +427
if (!body.targetId || !body.linkType) {
sendError(res, 400, "VALIDATION_ERROR", "targetId and linkType are required");
return;
}
const link = createLink(db, linksDocId, body.targetId, body.linkType as LinkType, body.label);
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

POST /api/v1/documents/:id/links: the parsed body is cast but not type-validated. targetId/linkType/label can be non-strings (or label an object), which can lead to inconsistent data or SQLite binding errors. Add typeof checks (and reject invalid types) before calling createLink().

Suggested change
if (!body.targetId || !body.linkType) {
sendError(res, 400, "VALIDATION_ERROR", "targetId and linkType are required");
return;
}
const link = createLink(db, linksDocId, body.targetId, body.linkType as LinkType, body.label);
if (typeof body.targetId !== "string" || body.targetId.trim() === "") {
sendError(res, 400, "VALIDATION_ERROR", "targetId must be a non-empty string");
return;
}
if (typeof body.linkType !== "string" || body.linkType.trim() === "") {
sendError(res, 400, "VALIDATION_ERROR", "linkType must be a non-empty string");
return;
}
if ("label" in body && typeof body.label !== "undefined" && typeof body.label !== "string") {
sendError(res, 400, "VALIDATION_ERROR", "label, if provided, must be a string");
return;
}
const link = createLink(
db,
linksDocId,
body.targetId,
body.linkType as LinkType,
body.label,
);

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: document cross-references and relationship links

2 participants