@imenam/mcp-documentor 1.0.57 → 1.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,7 @@ Connect it to Cursor, Claude Desktop, or any MCP-compatible agent to let your AI
8
8
 
9
9
  - **Create & update docs** — write markdown documentation entries linked to source files; prefer partial edits via `patch_doc`
10
10
  - **Staleness tracking** — automatically detects when tracked files have changed since the last doc update
11
+ - **Portable code references** — tracked-file paths are stored relative to a configurable code root (`MCP_DOCUMENTOR_CODE_ROOT`), so synced docs resolve their source files on any machine regardless of where the repos live
11
12
  - **Semantic search** — find relevant docs by meaning (requires an OpenAI API key)
12
13
  - **Multi-project support** — organize docs into separate projects; MCP tools work across projects; `set_project` only changes what the GUI shows
13
14
  - **GUI** — built-in web interface when a proxy is configured (`PROXY_URL`); without it, the GUI worker is not started
@@ -32,6 +33,7 @@ Global install exposes:
32
33
 
33
34
  - `mcp-documentor` — MCP server
34
35
  - `mcp-documentor-migrate` — CLI helper for migrating inline doc links (see `scripts/migrate-links.js`)
36
+ - `mcp-documentor-migrate-paths` — one-shot migration of tracked-file paths from absolute to code-root-relative (see [Portable tracked-file paths](#portable-tracked-file-paths))
35
37
 
36
38
  ## MCP Configuration
37
39
 
@@ -59,9 +61,11 @@ Add to your MCP client config (e.g. Cursor `mcp.json`):
59
61
  | `MCP_DOCUMENTOR_OPENAI_MODEL` | No | Embedding model (default: `text-embedding-3-small`) |
60
62
  | `PROXY_URL` | No | URL of the proxy server to register and launch the GUI |
61
63
  | `MCP_DATA_DIR` | No | Overrides the default data directory (see Data storage) |
64
+ | `MCP_DOCUMENTOR_CODE_ROOT` | No | Base directory that tracked-file paths are stored relative to, keeping docs portable across machines (see [Portable tracked-file paths](#portable-tracked-file-paths)) |
65
+ | `MCP_DOCUMENTOR_CODE_ROOTS` | No | JSON map of per-project code-root overrides, e.g. `{"my-proj":"/srv/my-proj"}` |
62
66
  | `READ_ONLY` | No | When set to `1`/`true`/`yes`, disables all create/update/delete tools and GUI write actions (read-only mode) |
63
67
  | `MCP_DOCUMENTOR_SYNC_REMOTE` | No | HTTPS URL of the private GitHub repo to sync with; enables GitHub sync when set |
64
- | `MCP_DOCUMENTOR_SYNC_BRANCH` | No | Branch to sync (default: `main`) |
68
+ | `MCP_DOCUMENTOR_SYNC_BRANCH` | No | Branch of the documentation sync repo to push/pull docs on (default: `main`) |
65
69
  | `MCP_DOCUMENTOR_SYNC_TOKEN` | No | Fine-grained GitHub PAT used for HTTPS auth (see GitHub sync) |
66
70
  | `SYNC_PULL_ON_START` | No | Set to `false` to disable the automatic pull at server startup |
67
71
 
@@ -148,7 +152,7 @@ The sync target is **administrator-controlled**: the repository, branch and toke
148
152
  "args": ["-y", "@imenam/mcp-documentor"],
149
153
  "env": {
150
154
  "MCP_DOCUMENTOR_SYNC_REMOTE": "https://github.com/<you>/my-docs.git",
151
- "MCP_DOCUMENTOR_SYNC_BRANCH": "main", // optional, defaults to "main"
155
+ "MCP_DOCUMENTOR_SYNC_BRANCH": "main", // branch of the docs sync repo, optional, defaults to "main"
152
156
  "MCP_DOCUMENTOR_SYNC_TOKEN": "github_pat_..."
153
157
  }
154
158
  }
@@ -177,6 +181,50 @@ then run `push_docs` to publish the resolution. If you have **uncommitted** loca
177
181
 
178
182
  > Read-only machines (`READ_ONLY=1`) can still `pull_docs` and `sync_status`, but `push_docs`, `sync_docs`, and `resolve_sync_conflict` are disabled.
179
183
 
184
+ ## Portable tracked-file paths
185
+
186
+ When docs are synced across machines, absolute tracked-file paths break: the codebase rarely lives at the same absolute location everywhere. To stay portable, tracked-file paths are stored **relative to a configurable code root** and resolved back to absolute paths on demand.
187
+
188
+ Agents keep passing **absolute** paths to `create_doc` / `update_doc` (and in `[copy-from-codebase|…]` tags) as usual — the conversion to a stored relative path happens automatically server-side. No change in how the agent works.
189
+
190
+ ### Configuration
191
+
192
+ Set the code root per machine (it is machine-local, so it lives in the environment, never in the synced data):
193
+
194
+ ```jsonc
195
+ {
196
+ "env": {
197
+ "MCP_DOCUMENTOR_CODE_ROOT": "/home/you/code/repos",
198
+ // optional per-project overrides when repos don't share a common parent:
199
+ "MCP_DOCUMENTOR_CODE_ROOTS": "{\"my-proj\":\"/srv/my-proj\"}"
200
+ }
201
+ }
202
+ ```
203
+
204
+ Resolution precedence: `MCP_DOCUMENTOR_CODE_ROOTS[project]` → `MCP_DOCUMENTOR_CODE_ROOT` → none.
205
+
206
+ | Stored path | On read |
207
+ |---|---|
208
+ | Relative + code root configured | resolved against the root |
209
+ | Relative + **no** root on this machine | reported as `unverifiable` (source absent) instead of a false `stale` |
210
+ | Absolute (legacy, or file outside the root) | used as-is |
211
+
212
+ The feature is **opt-in**: with no code root configured, behaviour is unchanged (paths stored and resolved as absolute).
213
+
214
+ ### Migrating existing docs
215
+
216
+ Docs created before this feature store absolute paths. Convert them in one shot with the bundled `mcp-documentor-migrate-paths`:
217
+
218
+ ```bash
219
+ MCP_DOCUMENTOR_CODE_ROOT="/home/you/code/repos" \
220
+ MCP_DATA_DIR="/path/to/your/data" \
221
+ npx -p @imenam/mcp-documentor mcp-documentor-migrate-paths --dry-run
222
+ ```
223
+
224
+ Review the report (`converted` / `kept absolute (outside root)` / `projects skipped`), then re-run without `--dry-run`. Finally publish with `sync_docs` (never commit the data dir by hand). The data dir is resolved like the server: `--data-dir` flag → `MCP_DATA_DIR` → package-relative fallback.
225
+
226
+ This is a **one-time** operation, and must run on the machine whose OS/filesystem **created** the stored absolute paths (path semantics differ between Windows and POSIX). Once migrated and synced, every other machine just `pull_docs` and sets its own `MCP_DOCUMENTOR_CODE_ROOT` — no migration needed.
227
+
180
228
  ## Data Storage
181
229
 
182
230
  By default, data lives under **`.docs-data`** next to the resolved package root (the folder that contains `package.json` for the running install). Set **`MCP_DATA_DIR`** to use another directory. Contents include projects, SQLite/embeddings, and logs under `.docs-data/logs`. This tree is **not** part of the npm package; it is created at runtime.
@@ -80,6 +80,7 @@ export declare const FreshnessReportSchema: z.ZodObject<{
80
80
  up_to_date: "up_to_date";
81
81
  stale: "stale";
82
82
  no_tracking: "no_tracking";
83
+ unverifiable: "unverifiable";
83
84
  }>;
84
85
  modified: z.ZodArray<z.ZodString>;
85
86
  missing: z.ZodArray<z.ZodString>;
@@ -36,7 +36,7 @@ export const DocSchema = z.object({
36
36
  updatedAt: z.string(),
37
37
  });
38
38
  export const FreshnessReportSchema = z.object({
39
- status: z.enum(["up_to_date", "stale", "no_tracking"]),
39
+ status: z.enum(["up_to_date", "stale", "no_tracking", "unverifiable"]),
40
40
  modified: z.array(z.string()),
41
41
  missing: z.array(z.string()),
42
42
  unchanged: z.array(z.string()),
@@ -47,7 +47,7 @@ export const CreateDocArgsSchema = z.object({
47
47
  title: z.string().describe("Title of the documentation entry"),
48
48
  content: z.string().optional().describe("Documentation content in markdown"),
49
49
  content_file: z.string().optional().describe("Absolute path to a text file whose content will be used instead of the content parameter. Use this to reuse a previously persisted draft."),
50
- tracked_files: z.array(TrackedFileInputSchema).optional().describe("Files to track for staleness detection. Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive."),
50
+ tracked_files: z.array(TrackedFileInputSchema).optional().describe("Files to track for staleness detection. Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive. Provide absolute paths; when MCP_DOCUMENTOR_CODE_ROOT is configured they are stored relative to it so the docs stay portable across machines."),
51
51
  tags: z.array(z.string()).optional().describe("Tags for categorization"),
52
52
  folder: z.string().optional().describe("Folder path for organization, e.g. 'frontend/components'"),
53
53
  related_docs: z.array(z.string()).optional().describe("IDs of related documentation entries"),
@@ -59,7 +59,7 @@ export const UpdateDocArgsSchema = z.object({
59
59
  title: z.string().optional().describe("Title of the documentation entry"),
60
60
  content: z.string().optional().describe("New content (replaces existing)"),
61
61
  content_file: z.string().optional().describe("Absolute path to a text file whose content will be used instead of the content parameter. Use this to reuse a previously persisted draft."),
62
- tracked_files: z.array(TrackedFileInputSchema).optional().describe("New list of tracked files (replaces existing). Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive."),
62
+ tracked_files: z.array(TrackedFileInputSchema).optional().describe("New list of tracked files (replaces existing). Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive. Provide absolute paths; when MCP_DOCUMENTOR_CODE_ROOT is configured they are stored relative to it so the docs stay portable across machines."),
63
63
  tags: z.array(z.string()).optional().describe("New tags (replaces existing)"),
64
64
  related_docs: z.array(z.string()).optional().describe("Paths of related documentation entries (replaces existing)"),
65
65
  });
@@ -80,7 +80,7 @@ export const SearchDocsArgsSchema = z.object({
80
80
  });
81
81
  export const RefreshFileSnapshotArgsSchema = z.object({
82
82
  paths: z.array(z.string()).min(1).describe("Paths of documentation entries to refresh, e.g. ['backend/auth/authentication', 'setup']"),
83
- file_paths: z.array(z.string()).optional().describe("Specific tracked file paths to refresh, as absolute paths (refreshes all tracked files if omitted)"),
83
+ file_paths: z.array(z.string()).optional().describe("Specific tracked files to refresh, as absolute paths (they are matched against the stored paths, relative or absolute). Refreshes all tracked files if omitted."),
84
84
  });
85
85
  export const GetDocMetadataArgsSchema = z.object({
86
86
  path: z.string().describe("Path of the documentation entry, e.g. 'backend/auth/authentication'"),
@@ -101,7 +101,7 @@ export const CheckLinksArgsSchema = z.object({
101
101
  });
102
102
  export const GitDiffDocArgsSchema = z.object({
103
103
  paths: z.array(z.string()).min(1).describe("Paths of documentation entries to diff, e.g. ['backend/auth/authentication']"),
104
- file_paths: z.array(z.string()).optional().describe("Specific tracked file paths to diff (all tracked files if omitted)"),
104
+ file_paths: z.array(z.string()).optional().describe("Specific tracked files to diff, as absolute paths (matched against the stored paths, relative or absolute). All tracked files if omitted."),
105
105
  });
106
106
  export const SetProjectArgsSchema = z.object({
107
107
  project: z.string().describe("Project name to switch to (created if it does not exist)"),
@@ -4,11 +4,11 @@ export interface CodeRefResult {
4
4
  additionalTrackedFiles: TrackedFileInput[];
5
5
  }
6
6
  export declare function resolveCodeRefs(content: string): CodeRefResult;
7
- export declare function snapshotTrackedFiles(inputs: TrackedFileInput[]): TrackedFile[];
7
+ export declare function snapshotTrackedFiles(inputs: TrackedFileInput[], project: string): TrackedFile[];
8
8
  /**
9
9
  * Merges snapshotted tracked files into an existing list.
10
10
  * - If a file is not yet tracked: appended.
11
11
  * - If a file is already tracked with sections and the new entry also has sections: sections are merged.
12
12
  * - Otherwise (whole-file tracking involved): the existing entry is replaced.
13
13
  */
14
- export declare function mergeTrackedFiles(existing: TrackedFile[], additions: TrackedFileInput[]): TrackedFile[];
14
+ export declare function mergeTrackedFiles(existing: TrackedFile[], additions: TrackedFileInput[], project: string): TrackedFile[];
@@ -2,6 +2,7 @@ import crypto from "crypto";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import { snapshotSection } from "./section-tracker.js";
5
+ import { toStoredPath } from "./path-resolver.js";
5
6
  const EXT_TO_LANG = {
6
7
  ts: "typescript",
7
8
  tsx: "tsx",
@@ -46,18 +47,21 @@ export function resolveCodeRefs(content) {
46
47
  });
47
48
  return { content: resolved, additionalTrackedFiles };
48
49
  }
49
- export function snapshotTrackedFiles(inputs) {
50
+ export function snapshotTrackedFiles(inputs, project) {
51
+ // Keyed on the *stored* (portable) path so two inputs pointing at the same
52
+ // file dedupe regardless of how the agent spelled the path.
50
53
  const byPath = new Map();
51
54
  for (const input of inputs) {
52
55
  const fp = typeof input === "string" ? input : input.path;
53
56
  const absolutePath = path.resolve(process.cwd(), fp);
57
+ const storedPath = toStoredPath(fp, project);
54
58
  const mtime = fs.existsSync(absolutePath)
55
59
  ? fs.statSync(absolutePath).mtime.toISOString()
56
60
  : new Date().toISOString();
57
61
  const isWhole = typeof input === "string" || input.lines.length === 0;
58
- const existing = byPath.get(absolutePath);
62
+ const existing = byPath.get(storedPath);
59
63
  if (isWhole) {
60
- byPath.set(absolutePath, { mtime, lineRanges: [], wholeFile: true });
64
+ byPath.set(storedPath, { absolutePath, mtime, lineRanges: [], wholeFile: true });
61
65
  }
62
66
  else if (existing) {
63
67
  if (!existing.wholeFile) {
@@ -65,7 +69,8 @@ export function snapshotTrackedFiles(inputs) {
65
69
  }
66
70
  }
67
71
  else {
68
- byPath.set(absolutePath, {
72
+ byPath.set(storedPath, {
73
+ absolutePath,
69
74
  mtime,
70
75
  lineRanges: [...input.lines],
71
76
  wholeFile: false,
@@ -73,16 +78,16 @@ export function snapshotTrackedFiles(inputs) {
73
78
  }
74
79
  }
75
80
  const tracked = [];
76
- for (const [absolutePath, { mtime, lineRanges, wholeFile }] of byPath) {
81
+ for (const [storedPath, { absolutePath, mtime, lineRanges, wholeFile }] of byPath) {
77
82
  if (!wholeFile && lineRanges.length > 0 && fs.existsSync(absolutePath)) {
78
83
  const sections = lineRanges
79
84
  .filter(({ start, end }) => start <= end)
80
85
  .map(({ start, end }) => snapshotSection(absolutePath, start, end));
81
- tracked.push({ path: absolutePath, lastModifiedAt: mtime, sections });
86
+ tracked.push({ path: storedPath, lastModifiedAt: mtime, sections });
82
87
  }
83
88
  else {
84
89
  const contentHash = fs.existsSync(absolutePath) ? computeFileHash(absolutePath) : undefined;
85
- tracked.push({ path: absolutePath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
90
+ tracked.push({ path: storedPath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
86
91
  }
87
92
  }
88
93
  return tracked;
@@ -93,10 +98,10 @@ export function snapshotTrackedFiles(inputs) {
93
98
  * - If a file is already tracked with sections and the new entry also has sections: sections are merged.
94
99
  * - Otherwise (whole-file tracking involved): the existing entry is replaced.
95
100
  */
96
- export function mergeTrackedFiles(existing, additions) {
101
+ export function mergeTrackedFiles(existing, additions, project) {
97
102
  if (additions.length === 0)
98
103
  return existing;
99
- const snapshotted = snapshotTrackedFiles(additions);
104
+ const snapshotted = snapshotTrackedFiles(additions, project);
100
105
  const result = [...existing];
101
106
  for (const newTf of snapshotted) {
102
107
  const idx = result.findIndex(tf => tf.path === newTf.path);
@@ -0,0 +1,20 @@
1
+ /** Test-only: clears the memoised env snapshot so a changed env is re-read. */
2
+ export declare function resetCodeRootsCache(): void;
3
+ /** The configured code root for a project, or undefined if none applies. */
4
+ export declare function getCodeRoot(project: string): string | undefined;
5
+ /**
6
+ * Converts an input path (typically the absolute path supplied by an agent)
7
+ * into the portable form persisted in a doc.
8
+ *
9
+ * Falls back to an absolute path — with a warning — when there is no code root,
10
+ * or when the file lives outside the root (including a different Windows drive).
11
+ */
12
+ export declare function toStoredPath(inputPath: string, project: string): string;
13
+ /**
14
+ * Resolves a stored path to an absolute filesystem path on this machine.
15
+ *
16
+ * - absolute stored path (legacy / fallback) → returned as-is
17
+ * - relative stored path + configured root → joined with the root
18
+ * - relative stored path + no root configured → undefined (unverifiable here)
19
+ */
20
+ export declare function toAbsolutePath(stored: string, project: string): string | undefined;
@@ -0,0 +1,82 @@
1
+ import path from "path";
2
+ import { createLogger } from "@imenam/mcp-gui-interface";
3
+ const logger = createLogger("PathResolver");
4
+ let cached;
5
+ function loadCodeRoots() {
6
+ if (cached)
7
+ return cached;
8
+ const defaultRoot = process.env.MCP_DOCUMENTOR_CODE_ROOT?.trim() || undefined;
9
+ let byProject = {};
10
+ const rawMap = process.env.MCP_DOCUMENTOR_CODE_ROOTS?.trim();
11
+ if (rawMap) {
12
+ try {
13
+ const parsed = JSON.parse(rawMap);
14
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
15
+ for (const [proj, root] of Object.entries(parsed)) {
16
+ if (typeof root === "string" && root.trim() !== "") {
17
+ byProject[proj] = root.trim();
18
+ }
19
+ }
20
+ }
21
+ else {
22
+ logger.warn("MCP_DOCUMENTOR_CODE_ROOTS is not a JSON object, ignoring it");
23
+ }
24
+ }
25
+ catch {
26
+ logger.warn("Failed to parse MCP_DOCUMENTOR_CODE_ROOTS as JSON, ignoring it");
27
+ }
28
+ }
29
+ cached = { default: defaultRoot, byProject };
30
+ return cached;
31
+ }
32
+ /** Test-only: clears the memoised env snapshot so a changed env is re-read. */
33
+ export function resetCodeRootsCache() {
34
+ cached = undefined;
35
+ }
36
+ /** The configured code root for a project, or undefined if none applies. */
37
+ export function getCodeRoot(project) {
38
+ const roots = loadCodeRoots();
39
+ return roots.byProject[project] ?? roots.default;
40
+ }
41
+ function toForwardSlashes(p) {
42
+ return p.split(path.sep).join("/");
43
+ }
44
+ /**
45
+ * Converts an input path (typically the absolute path supplied by an agent)
46
+ * into the portable form persisted in a doc.
47
+ *
48
+ * Falls back to an absolute path — with a warning — when there is no code root,
49
+ * or when the file lives outside the root (including a different Windows drive).
50
+ */
51
+ export function toStoredPath(inputPath, project) {
52
+ const abs = path.resolve(process.cwd(), inputPath);
53
+ const root = getCodeRoot(project);
54
+ if (!root) {
55
+ return toForwardSlashes(abs);
56
+ }
57
+ const rel = path.relative(path.resolve(root), abs);
58
+ // `rel` escaping the root ("../…") or being absolute (different drive on
59
+ // Windows) means the file is not under the root — keep it absolute.
60
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
61
+ logger.warn(`tracked file "${abs}" is outside the code root for project "${project}" (${root}); storing as an absolute path`);
62
+ return toForwardSlashes(abs);
63
+ }
64
+ return toForwardSlashes(rel);
65
+ }
66
+ /**
67
+ * Resolves a stored path to an absolute filesystem path on this machine.
68
+ *
69
+ * - absolute stored path (legacy / fallback) → returned as-is
70
+ * - relative stored path + configured root → joined with the root
71
+ * - relative stored path + no root configured → undefined (unverifiable here)
72
+ */
73
+ export function toAbsolutePath(stored, project) {
74
+ if (path.isAbsolute(stored)) {
75
+ return stored;
76
+ }
77
+ const root = getCodeRoot(project);
78
+ if (!root) {
79
+ return undefined;
80
+ }
81
+ return path.resolve(root, stored);
82
+ }
package/dist/src/index.js CHANGED
@@ -18,6 +18,7 @@ import { updateDoc } from "./tools/update-doc.js";
18
18
  import { searchDocs } from "./tools/search-docs.js";
19
19
  import { checkFreshness, computeFreshness } from "./tools/check-freshness.js";
20
20
  import { findAndHashSection } from "./core/section-tracker.js";
21
+ import { toAbsolutePath } from "./core/path-resolver.js";
21
22
  import { setProject } from "./tools/set-project.js";
22
23
  import { listProjects } from "./tools/list-projects.js";
23
24
  import { refreshFileSnapshot } from "./tools/refresh-file-snapshot.js";
@@ -158,7 +159,7 @@ async function handleGetInitialState(msg) {
158
159
  for (const doc of docs) {
159
160
  const full = await storageEngine.getDoc(doc.id); // doc.id is now the full path
160
161
  if (full && full.tracked_files.length > 0) {
161
- const report = computeFreshness(full.tracked_files);
162
+ const report = computeFreshness(full.tracked_files, config.currentProject);
162
163
  doc.isStale = report.status === "stale";
163
164
  }
164
165
  }
@@ -196,7 +197,7 @@ async function handleUpdateProject(msg) {
196
197
  for (const doc of docs) {
197
198
  const full = await storageEngine.getDoc(doc.id);
198
199
  if (full && full.tracked_files.length > 0) {
199
- const report = computeFreshness(full.tracked_files);
200
+ const report = computeFreshness(full.tracked_files, project);
200
201
  doc.isStale = report.status === "stale";
201
202
  }
202
203
  }
@@ -232,11 +233,12 @@ async function handleListProjects(msg) {
232
233
  }
233
234
  async function handleListDocs(msg) {
234
235
  try {
236
+ const currentProject = storageEngine.getConfig().currentProject;
235
237
  const docs = await storageEngine.listDocs();
236
238
  for (const doc of docs) {
237
239
  const full = await storageEngine.getDoc(doc.id);
238
240
  if (full && full.tracked_files.length > 0) {
239
- const report = computeFreshness(full.tracked_files);
241
+ const report = computeFreshness(full.tracked_files, currentProject);
240
242
  doc.isStale = report.status === "stale";
241
243
  }
242
244
  }
@@ -344,15 +346,15 @@ async function handleCreateDocIPC(msg) {
344
346
  data: { success: true, id },
345
347
  timestamp: new Date().toISOString(),
346
348
  });
349
+ const config = storageEngine.getConfig();
347
350
  const docs = await storageEngine.listDocs();
348
351
  for (const doc of docs) {
349
352
  const full = await storageEngine.getDoc(doc.id);
350
353
  if (full && full.tracked_files.length > 0) {
351
- const report = computeFreshness(full.tracked_files);
354
+ const report = computeFreshness(full.tracked_files, config.currentProject);
352
355
  doc.isStale = report.status === "stale";
353
356
  }
354
357
  }
355
- const config = storageEngine.getConfig();
356
358
  broadcastToGUI({
357
359
  type: "INITIAL_STATE",
358
360
  data: { config, docs, projects: config.projects },
@@ -420,7 +422,9 @@ async function handleGetFileSection(msg) {
420
422
  const trackedFile = resolved.doc.tracked_files.find((f) => f.path === filePath);
421
423
  if (!trackedFile || !trackedFile.sections?.length)
422
424
  throw new Error("No sections for this file");
423
- const absolutePath = path.resolve(process.cwd(), filePath);
425
+ const absolutePath = toAbsolutePath(trackedFile.path, resolved.project);
426
+ if (!absolutePath)
427
+ throw new Error("Cannot resolve file on this machine (no code root configured)");
424
428
  const fileLines = fs.readFileSync(absolutePath, "utf-8").split("\n");
425
429
  const sections = trackedFile.sections.map((section) => {
426
430
  const result = findAndHashSection(absolutePath, section);
@@ -462,7 +466,7 @@ async function handleCheckFreshness(msg) {
462
466
  });
463
467
  return;
464
468
  }
465
- const report = computeFreshness(doc.tracked_files);
469
+ const report = computeFreshness(doc.tracked_files, resolved.project);
466
470
  broadcastToGUI({
467
471
  type: "CHECK_FRESHNESS_RESPONSE",
468
472
  correlationId: msg.correlationId,
@@ -1,7 +1,7 @@
1
1
  import type { TrackedFile, FreshnessReport } from "../../shared/schemas/doc.schema.js";
2
2
  import type { StorageEngine } from "../core/storage-engine.js";
3
3
  export declare function computeFileHash(absolutePath: string): string;
4
- export declare function computeFreshness(trackedFiles: TrackedFile[]): FreshnessReport;
4
+ export declare function computeFreshness(trackedFiles: TrackedFile[], project: string): FreshnessReport;
5
5
  export declare function formatFreshnessReport(title: string, report: FreshnessReport): string;
6
6
  export declare function checkFreshness(storageEngine: StorageEngine, args: unknown): Promise<{
7
7
  isError: boolean;
@@ -1,12 +1,12 @@
1
1
  import crypto from "crypto";
2
2
  import fs from "fs";
3
- import path from "path";
4
3
  import { CheckFreshnessArgsSchema } from "../../shared/schemas/doc.schema.js";
5
4
  import { findAndHashSection } from "../core/section-tracker.js";
5
+ import { toAbsolutePath } from "../core/path-resolver.js";
6
6
  export function computeFileHash(absolutePath) {
7
7
  return crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex");
8
8
  }
9
- export function computeFreshness(trackedFiles) {
9
+ export function computeFreshness(trackedFiles, project) {
10
10
  if (!trackedFiles || trackedFiles.length === 0) {
11
11
  return { status: "no_tracking", modified: [], missing: [], unchanged: [], anchors_lost: [], total: 0 };
12
12
  }
@@ -15,8 +15,11 @@ export function computeFreshness(trackedFiles) {
15
15
  const unchanged = [];
16
16
  const anchors_lost = [];
17
17
  for (const tracked of trackedFiles) {
18
- const absolutePath = path.resolve(process.cwd(), tracked.path);
19
- if (!fs.existsSync(absolutePath)) {
18
+ // A relative path with no code root configured on this machine is not
19
+ // resolvable here — count it as absent so we end up "unverifiable" rather
20
+ // than crying wolf with "stale".
21
+ const absolutePath = toAbsolutePath(tracked.path, project);
22
+ if (!absolutePath || !fs.existsSync(absolutePath)) {
20
23
  missing.push(tracked.path);
21
24
  continue;
22
25
  }
@@ -68,6 +71,15 @@ export function computeFreshness(trackedFiles) {
68
71
  }
69
72
  }
70
73
  }
74
+ // When every tracked file is absent, the source code simply isn't present on
75
+ // this machine (e.g. docs cloned without the codebase) — we can't verify
76
+ // freshness, so report "unverifiable" rather than crying wolf with "stale".
77
+ // A partial absence (some files missing, others present) is treated as a real
78
+ // change signal (likely deletions) and still counts as stale.
79
+ const allMissing = missing.length === trackedFiles.length;
80
+ if (allMissing) {
81
+ return { status: "unverifiable", modified, missing, unchanged, anchors_lost, total: trackedFiles.length };
82
+ }
71
83
  const isStale = modified.length > 0 || missing.length > 0 || anchors_lost.length > 0;
72
84
  return {
73
85
  status: isStale ? "stale" : "up_to_date",
@@ -82,6 +94,9 @@ export function formatFreshnessReport(title, report) {
82
94
  if (report.status === "no_tracking") {
83
95
  return `No tracked files for "${title}". Use tracked_files when creating or updating the doc to enable staleness detection.`;
84
96
  }
97
+ if (report.status === "unverifiable") {
98
+ return `UNVERIFIABLE: "${title}" — none of the ${report.total} tracked file(s) are present on this machine, so freshness cannot be checked here. This is expected when the documentation is cloned without its source codebase.`;
99
+ }
85
100
  let text = report.status === "stale"
86
101
  ? `STALE: "${title}" — tracked files have changed since the documentation was last updated.\n`
87
102
  : `UP TO DATE: "${title}" — no tracked files have been modified.\n`;
@@ -100,6 +115,15 @@ export function formatFreshnessReport(title, report) {
100
115
  }
101
116
  return text;
102
117
  }
118
+ /** Cap on how many "not tracking any file" doc titles are listed before collapsing into a count. */
119
+ const UNTRACKED_LIST_LIMIT = 10;
120
+ /** Renders the untracked-docs list, truncated past UNTRACKED_LIST_LIMIT to avoid flooding the context. */
121
+ function renderUntrackedList(titles, heading) {
122
+ const shown = titles.slice(0, UNTRACKED_LIST_LIMIT).map((t) => ` - ${t}`);
123
+ const remaining = titles.length - UNTRACKED_LIST_LIMIT;
124
+ const suffix = remaining > 0 ? `\n … and ${remaining} more` : "";
125
+ return `\n\n${heading}\n${shown.join("\n")}${suffix}`;
126
+ }
103
127
  export async function checkFreshness(storageEngine, args) {
104
128
  try {
105
129
  const validated = CheckFreshnessArgsSchema.parse(args);
@@ -108,8 +132,8 @@ export async function checkFreshness(storageEngine, args) {
108
132
  if (!resolved) {
109
133
  return { isError: true, content: [{ type: "text", text: `Documentation entry "${validated.path}" not found in any project.` }] };
110
134
  }
111
- const { doc } = resolved;
112
- const report = computeFreshness(doc.tracked_files);
135
+ const { doc, project } = resolved;
136
+ const report = computeFreshness(doc.tracked_files, project);
113
137
  return { content: [{ type: "text", text: formatFreshnessReport(doc.title, report) }] };
114
138
  }
115
139
  if (validated.project) {
@@ -120,14 +144,18 @@ export async function checkFreshness(storageEngine, args) {
120
144
  const stale = [];
121
145
  const noTracking = [];
122
146
  let upToDate = 0;
147
+ let unverifiable = 0;
123
148
  for (const summary of summaries) {
124
149
  const doc = await storageEngine.getDoc(summary.id, validated.project);
125
150
  if (!doc)
126
151
  continue;
127
- const report = computeFreshness(doc.tracked_files);
152
+ const report = computeFreshness(doc.tracked_files, validated.project);
128
153
  if (report.status === "no_tracking") {
129
154
  noTracking.push(doc.title);
130
155
  }
156
+ else if (report.status === "unverifiable") {
157
+ unverifiable++;
158
+ }
131
159
  else if (report.status === "stale") {
132
160
  const details = [
133
161
  ...report.modified.map((f) => ` • modified: ${f}`),
@@ -141,12 +169,12 @@ export async function checkFreshness(storageEngine, args) {
141
169
  }
142
170
  }
143
171
  let text = `# Freshness report — project "${validated.project}"\n\n`;
144
- text += `${summaries.length} doc(s) scanned: **${stale.length} stale**, ${upToDate} up to date, ${noTracking.length} untracked.\n`;
172
+ text += `${summaries.length} doc(s) scanned: **${stale.length} stale**, ${upToDate} up to date, ${unverifiable} unverifiable (source absent), ${noTracking.length} not tracking any file.\n`;
145
173
  if (stale.length > 0) {
146
174
  text += `\n## Stale docs\n\n${stale.join("\n\n")}`;
147
175
  }
148
176
  if (noTracking.length > 0) {
149
- text += `\n\n## Untracked docs (no staleness detection)\n${noTracking.map((t) => ` - ${t}`).join("\n")}`;
177
+ text += renderUntrackedList(noTracking, "## Docs not tracking any file (no staleness detection)");
150
178
  }
151
179
  return { content: [{ type: "text", text }] };
152
180
  }
@@ -161,14 +189,18 @@ export async function checkFreshness(storageEngine, args) {
161
189
  const stale = [];
162
190
  const noTracking = [];
163
191
  let upToDate = 0;
192
+ let unverifiable = 0;
164
193
  for (const summary of summaries) {
165
194
  const doc = await storageEngine.getDoc(summary.id, proj);
166
195
  if (!doc)
167
196
  continue;
168
- const report = computeFreshness(doc.tracked_files);
197
+ const report = computeFreshness(doc.tracked_files, proj);
169
198
  if (report.status === "no_tracking") {
170
199
  noTracking.push(doc.title);
171
200
  }
201
+ else if (report.status === "unverifiable") {
202
+ unverifiable++;
203
+ }
172
204
  else if (report.status === "stale") {
173
205
  const details = [
174
206
  ...report.modified.map((f) => ` • modified: ${f}`),
@@ -184,12 +216,12 @@ export async function checkFreshness(storageEngine, args) {
184
216
  totalDocs += summaries.length;
185
217
  totalStale += stale.length;
186
218
  let block = `## ${proj}\n\n`;
187
- block += `${summaries.length} doc(s): **${stale.length} stale**, ${upToDate} up to date, ${noTracking.length} untracked.`;
219
+ block += `${summaries.length} doc(s): **${stale.length} stale**, ${upToDate} up to date, ${unverifiable} unverifiable (source absent), ${noTracking.length} not tracking any file.`;
188
220
  if (stale.length > 0) {
189
221
  block += `\n\n### Stale docs\n\n${stale.join("\n\n")}`;
190
222
  }
191
223
  if (noTracking.length > 0) {
192
- block += `\n\n### Untracked docs\n${noTracking.map((t) => ` - ${t}`).join("\n")}`;
224
+ block += renderUntrackedList(noTracking, "### Docs not tracking any file");
193
225
  }
194
226
  projectBlocks.push(block);
195
227
  }
@@ -31,11 +31,12 @@ export async function createDoc(storageEngine, embeddingEngine, args) {
31
31
  if (project) {
32
32
  storageEngine.ensureProject(project);
33
33
  }
34
+ const resolvedProject = project ?? storageEngine.getConfig().currentProject;
34
35
  const allTrackedInputs = [
35
36
  ...(validated.tracked_files ?? []),
36
37
  ...additionalTrackedFiles,
37
38
  ];
38
- const trackedFiles = allTrackedInputs.length > 0 ? snapshotTrackedFiles(allTrackedInputs) : [];
39
+ const trackedFiles = allTrackedInputs.length > 0 ? snapshotTrackedFiles(allTrackedInputs, resolvedProject) : [];
39
40
  const folder = validated.folder;
40
41
  const doc = {
41
42
  id,
@@ -48,7 +49,6 @@ export async function createDoc(storageEngine, embeddingEngine, args) {
48
49
  createdAt: now,
49
50
  updatedAt: now,
50
51
  };
51
- const resolvedProject = project ?? storageEngine.getConfig().currentProject;
52
52
  await storageEngine.saveDoc(doc, resolvedProject, "create");
53
53
  if (embeddingEngine.isConfigured()) {
54
54
  const embedding = await embeddingEngine.generateEmbedding(content);
@@ -1,6 +1,8 @@
1
+ import fs from "fs";
1
2
  import { GetDocMetadataArgsSchema } from "../../shared/schemas/doc.schema.js";
2
3
  import { computeFreshness } from "./check-freshness.js";
3
4
  import { findAndHashSection } from "../core/section-tracker.js";
5
+ import { toAbsolutePath } from "../core/path-resolver.js";
4
6
  export async function getDocMetadata(storageEngine, args) {
5
7
  try {
6
8
  const validated = GetDocMetadataArgsSchema.parse(args);
@@ -8,8 +10,8 @@ export async function getDocMetadata(storageEngine, args) {
8
10
  if (!resolved) {
9
11
  return { isError: true, content: [{ type: "text", text: `Documentation entry "${validated.path}" not found in any project.` }] };
10
12
  }
11
- const { doc } = resolved;
12
- const report = computeFreshness(doc.tracked_files);
13
+ const { doc, project } = resolved;
14
+ const report = computeFreshness(doc.tracked_files, project);
13
15
  const fileStatusIcon = (filePath) => {
14
16
  if (report.modified.includes(filePath))
15
17
  return "✗ modified";
@@ -28,8 +30,10 @@ export async function getDocMetadata(storageEngine, args) {
28
30
  text += doc.tracked_files.map((f) => {
29
31
  let entry = ` ${fileStatusIcon(f.path)} — ${f.path}\n File mtime: ${f.lastModifiedAt}`;
30
32
  if (f.sections && f.sections.length > 0) {
33
+ const abs = toAbsolutePath(f.path, project);
34
+ const readable = abs && fs.existsSync(abs);
31
35
  const sectionLines = f.sections.map((section, idx) => {
32
- const result = findAndHashSection(f.path, section);
36
+ const result = readable ? findAndHashSection(abs, section) : { found: false };
33
37
  const location = result.found
34
38
  ? `lines ${result.lines.start}–${result.lines.end}`
35
39
  : "section introuvable (ancres modifiées)";
@@ -1,6 +1,8 @@
1
+ import fs from "fs";
1
2
  import { GetDocArgsSchema } from "../../shared/schemas/doc.schema.js";
2
3
  import { computeFreshness } from "./check-freshness.js";
3
4
  import { findAndHashSection } from "../core/section-tracker.js";
5
+ import { toAbsolutePath } from "../core/path-resolver.js";
4
6
  export async function getDoc(storageEngine, args) {
5
7
  try {
6
8
  const validated = GetDocArgsSchema.parse(args);
@@ -12,8 +14,10 @@ export async function getDoc(storageEngine, args) {
12
14
  const trackedFilesText = doc.tracked_files.length > 0
13
15
  ? doc.tracked_files.map((f) => {
14
16
  if (f.sections && f.sections.length > 0) {
17
+ const abs = toAbsolutePath(f.path, resolvedProject);
18
+ const readable = abs && fs.existsSync(abs);
15
19
  const sectionLines = f.sections.map((section, idx) => {
16
- const result = findAndHashSection(f.path, section);
20
+ const result = readable ? findAndHashSection(abs, section) : { found: false };
17
21
  const location = result.found
18
22
  ? `lines ${result.lines.start}–${result.lines.end}`
19
23
  : "section introuvable";
@@ -24,7 +28,7 @@ export async function getDoc(storageEngine, args) {
24
28
  return ` - ${f.path} (snapshot: ${f.lastModifiedAt})`;
25
29
  }).join("\n")
26
30
  : " (none)";
27
- const freshnessReport = computeFreshness(doc.tracked_files);
31
+ const freshnessReport = computeFreshness(doc.tracked_files, resolvedProject);
28
32
  const stalenessBanner = freshnessReport.status === "stale"
29
33
  ? [
30
34
  `> [!WARNING]`,
@@ -2,8 +2,12 @@ import { execSync } from "child_process";
2
2
  import path from "path";
3
3
  import { GitDiffDocArgsSchema } from "../../shared/schemas/doc.schema.js";
4
4
  import { computeFreshness } from "./check-freshness.js";
5
- function getGitDiffForFile(tracked) {
6
- const filePath = tracked.path;
5
+ import { toAbsolutePath, toStoredPath } from "../core/path-resolver.js";
6
+ function getGitDiffForFile(tracked, project) {
7
+ const filePath = toAbsolutePath(tracked.path, project);
8
+ if (!filePath) {
9
+ return ` [INFO] Cannot resolve "${tracked.path}" on this machine (no code root configured) — source absent.`;
10
+ }
7
11
  const fileDir = path.dirname(filePath);
8
12
  let repoRoot;
9
13
  try {
@@ -46,17 +50,18 @@ export async function gitDiffDoc(storageEngine, args) {
46
50
  docSections.push(`## ${docPath}\n\n[ERROR] Documentation entry not found in any project.`);
47
51
  continue;
48
52
  }
49
- const { doc } = resolved;
53
+ const { doc, project } = resolved;
50
54
  if (!doc.tracked_files || doc.tracked_files.length === 0) {
51
55
  docSections.push(`## ${doc.title}\n\n[INFO] No tracked files.`);
52
56
  continue;
53
57
  }
54
58
  let filesToDiff;
55
59
  if (validated.file_paths) {
56
- filesToDiff = doc.tracked_files.filter((tf) => validated.file_paths.includes(tf.path));
60
+ const wanted = new Set(validated.file_paths.map((p) => toStoredPath(p, project)));
61
+ filesToDiff = doc.tracked_files.filter((tf) => wanted.has(tf.path));
57
62
  }
58
63
  else {
59
- const freshness = computeFreshness(doc.tracked_files);
64
+ const freshness = computeFreshness(doc.tracked_files, project);
60
65
  const changedPaths = new Set([...freshness.modified, ...freshness.missing]);
61
66
  filesToDiff = doc.tracked_files.filter((tf) => changedPaths.has(tf.path));
62
67
  }
@@ -75,7 +80,7 @@ export async function gitDiffDoc(storageEngine, args) {
75
80
  return true;
76
81
  })
77
82
  .map((tf) => {
78
- const diff = getGitDiffForFile(tf);
83
+ const diff = getGitDiffForFile(tf, project);
79
84
  return `### ${tf.path}\nSnapshot date: ${tf.lastModifiedAt}\n\n${diff}`;
80
85
  });
81
86
  if (fileSections.length === 0) {
@@ -41,7 +41,7 @@ export async function listDocs(storageEngine, args) {
41
41
  for (const summary of summaries) {
42
42
  const doc = await storageEngine.getDoc(summary.id, proj);
43
43
  if (doc && doc.tracked_files.length > 0) {
44
- const report = computeFreshness(doc.tracked_files);
44
+ const report = computeFreshness(doc.tracked_files, proj);
45
45
  summary.isStale = report.status === "stale";
46
46
  }
47
47
  }
@@ -59,7 +59,7 @@ export async function listDocs(storageEngine, args) {
59
59
  for (const summary of summaries) {
60
60
  const doc = await storageEngine.getDoc(summary.id, proj);
61
61
  if (doc && doc.tracked_files.length > 0) {
62
- const report = computeFreshness(doc.tracked_files);
62
+ const report = computeFreshness(doc.tracked_files, proj);
63
63
  summary.isStale = report.status === "stale";
64
64
  }
65
65
  }
@@ -28,7 +28,7 @@ export async function patchDoc(storageEngine, embeddingEngine, args) {
28
28
  };
29
29
  }
30
30
  doc.content = doc.content.replace(search, replacement);
31
- doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles);
31
+ doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles, resolvedProject);
32
32
  doc.updatedAt = new Date().toISOString();
33
33
  await storageEngine.saveDoc(doc, resolvedProject, "patch");
34
34
  if (embeddingEngine.isConfigured()) {
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import { RefreshFileSnapshotArgsSchema } from "../../shared/schemas/doc.schema.js";
3
3
  import { computeFileHash } from "./check-freshness.js";
4
4
  import { relocateAndRefreshSection } from "../core/section-tracker.js";
5
+ import { toAbsolutePath, toStoredPath } from "../core/path-resolver.js";
5
6
  export async function refreshFileSnapshot(storageEngine, historyStore, args) {
6
7
  try {
7
8
  const validated = RefreshFileSnapshotArgsSchema.parse(args);
@@ -17,24 +18,26 @@ export async function refreshFileSnapshot(storageEngine, historyStore, args) {
17
18
  const missing = [];
18
19
  const skipped = [];
19
20
  const anchorsLost = [];
20
- // Normalize to forward slashes and lowercase for case-insensitive comparison on Windows
21
- // (e.g. stored 'c:\...' vs provided 'C:\...'). file_paths must be absolute paths.
21
+ // Normalize to forward slashes and lowercase for case-insensitive comparison
22
+ // on Windows. Agent-supplied file_paths are absolute; map them to the stored
23
+ // (portable) form so they match tracked.path regardless of representation.
22
24
  const norm = (p) => p.replace(/\\/g, "/").toLowerCase();
23
- const resolvedFilePaths = validated.file_paths?.map(norm);
25
+ const resolvedFilePaths = validated.file_paths?.map((p) => norm(toStoredPath(p, resolvedProject)));
24
26
  for (const tracked of doc.tracked_files) {
25
27
  if (resolvedFilePaths && !resolvedFilePaths.includes(norm(tracked.path))) {
26
28
  skipped.push(tracked.path);
27
29
  continue;
28
30
  }
29
- if (!fs.existsSync(tracked.path)) {
31
+ const absolutePath = toAbsolutePath(tracked.path, resolvedProject);
32
+ if (!absolutePath || !fs.existsSync(absolutePath)) {
30
33
  missing.push(tracked.path);
31
34
  continue;
32
35
  }
33
- tracked.lastModifiedAt = fs.statSync(tracked.path).mtime.toISOString();
36
+ tracked.lastModifiedAt = fs.statSync(absolutePath).mtime.toISOString();
34
37
  if (tracked.sections && tracked.sections.length > 0) {
35
38
  let hasLostAnchors = false;
36
39
  const refreshedSections = tracked.sections.map((section) => {
37
- const result = relocateAndRefreshSection(tracked.path, section);
40
+ const result = relocateAndRefreshSection(absolutePath, section);
38
41
  if (!result.found)
39
42
  hasLostAnchors = true;
40
43
  return result.found ? result.refreshed : section;
@@ -48,7 +51,7 @@ export async function refreshFileSnapshot(storageEngine, historyStore, args) {
48
51
  }
49
52
  }
50
53
  else {
51
- tracked.contentHash = computeFileHash(tracked.path);
54
+ tracked.contentHash = computeFileHash(absolutePath);
52
55
  refreshed.push(tracked.path);
53
56
  }
54
57
  }
@@ -28,10 +28,10 @@ export async function updateDoc(storageEngine, embeddingEngine, args) {
28
28
  doc.tracked_files = snapshotTrackedFiles([
29
29
  ...validated.tracked_files,
30
30
  ...additionalTrackedFiles,
31
- ]);
31
+ ], resolvedProject);
32
32
  }
33
33
  else {
34
- doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles);
34
+ doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles, resolvedProject);
35
35
  }
36
36
  if (validated.tags !== undefined) {
37
37
  doc.tags = validated.tags;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.57";
1
+ export declare const APP_VERSION = "1.0.59";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated version file - do not edit manually
2
- export const APP_VERSION = '1.0.57';
2
+ export const APP_VERSION = '1.0.59';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-documentor",
3
- "version": "1.0.57",
3
+ "version": "1.0.59",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",
@@ -41,7 +41,8 @@
41
41
  "type": "module",
42
42
  "bin": {
43
43
  "mcp-documentor": "dist/src/index.js",
44
- "mcp-documentor-migrate": "scripts/migrate-links.js"
44
+ "mcp-documentor-migrate": "scripts/migrate-links.js",
45
+ "mcp-documentor-migrate-paths": "scripts/migrate-paths-to-relative.js"
45
46
  },
46
47
  "files": [
47
48
  "dist",
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * migrate-paths-to-relative.js
4
+ *
5
+ * One-shot migration that rewrites the absolute paths stored in each doc's
6
+ * `tracked_files[].path` into the portable, code-root-relative form introduced
7
+ * alongside GitHub documentation sync. Portable paths let an agent running on a
8
+ * different machine (where the codebase lives at a different absolute location)
9
+ * resolve tracked files correctly.
10
+ *
11
+ * Resolution reuses the compiled path-resolver (dist/) so it behaves exactly
12
+ * like the running server — RUN `npm run build` FIRST.
13
+ *
14
+ * The conversion is driven by the same environment variables as the server:
15
+ * MCP_DOCUMENTOR_CODE_ROOT default code root
16
+ * MCP_DOCUMENTOR_CODE_ROOTS JSON map of per-project overrides
17
+ *
18
+ * A tracked file whose absolute path is NOT under the project's code root is
19
+ * left absolute and reported, so nothing is silently broken.
20
+ *
21
+ * The data dir is resolved like the server: --data-dir flag, else MCP_DATA_DIR,
22
+ * else <package>/data. The MCP_DATA_DIR fallback is what makes npx usable.
23
+ *
24
+ * Usage (from a clone):
25
+ * node scripts/migrate-paths-to-relative.js [--dry-run] [--data-dir <dir>]
26
+ *
27
+ * Usage (npx install — no clone):
28
+ * MCP_DOCUMENTOR_CODE_ROOT="<repos root>" MCP_DATA_DIR="<your data dir>" \
29
+ * npx -p @imenam/mcp-documentor mcp-documentor-migrate-paths --dry-run
30
+ *
31
+ * This migration is a ONE-TIME operation: run it once on the machine where the
32
+ * stored absolute paths are valid, publish the result (sync_docs / push_docs),
33
+ * and every other machine just pulls and sets its own MCP_DOCUMENTOR_CODE_ROOT.
34
+ */
35
+
36
+ import fs from "fs";
37
+ import path from "path";
38
+ import { fileURLToPath, pathToFileURL } from "url";
39
+ import matter from "gray-matter";
40
+
41
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
+ const rootDir = path.join(__dirname, "..");
43
+
44
+ const args = process.argv.slice(2);
45
+ const dryRun = args.includes("--dry-run");
46
+
47
+ // Data dir resolution mirrors the server (StorageEngine.getDataDir):
48
+ // --data-dir flag > MCP_DATA_DIR env > <package>/data fallback.
49
+ // The env fallback is what makes `npx` work: the data lives in the user's
50
+ // MCP_DATA_DIR, not inside the npx package cache.
51
+ const dataDirArgIdx = args.indexOf("--data-dir");
52
+ const dataDir =
53
+ dataDirArgIdx !== -1 && args[dataDirArgIdx + 1]
54
+ ? args[dataDirArgIdx + 1]
55
+ : process.env.MCP_DATA_DIR && process.env.MCP_DATA_DIR.trim() !== ""
56
+ ? process.env.MCP_DATA_DIR.trim()
57
+ : path.join(rootDir, "data");
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Reuse the compiled resolver so migration matches runtime behaviour exactly.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const resolverPath = path.join(rootDir, "dist", "src", "core", "path-resolver.js");
64
+ if (!fs.existsSync(resolverPath)) {
65
+ console.error(`[ERROR] Compiled resolver not found at: ${resolverPath}`);
66
+ console.error(" Run `npm run build` before this migration.");
67
+ process.exit(1);
68
+ }
69
+ const { getCodeRoot, toStoredPath } = await import(pathToFileURL(resolverPath).href);
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Helpers
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function walkMdFiles(dir) {
76
+ if (!fs.existsSync(dir)) return [];
77
+ const results = [];
78
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
79
+ const fullPath = path.join(dir, entry.name);
80
+ if (entry.isDirectory()) {
81
+ results.push(...walkMdFiles(fullPath));
82
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
83
+ results.push(fullPath);
84
+ }
85
+ }
86
+ return results;
87
+ }
88
+
89
+ function filePathToDocId(filePath, projectDir) {
90
+ return path.relative(projectDir, filePath).replace(/\.md$/, "").split(path.sep).join("/");
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Load config
95
+ // ---------------------------------------------------------------------------
96
+
97
+ const configPath = path.join(dataDir, "config.json");
98
+ if (!fs.existsSync(configPath)) {
99
+ console.error(`[ERROR] config.json not found at: ${configPath}`);
100
+ console.error(" Make sure --data-dir points to the right directory.");
101
+ process.exit(1);
102
+ }
103
+
104
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
105
+ const projects = config.projects ?? ["default"];
106
+
107
+ console.log(`${dryRun ? "[DRY RUN] " : ""}migrate-paths-to-relative`);
108
+ console.log(`Data dir : ${dataDir}`);
109
+ console.log(`Projects : ${projects.length}`);
110
+ console.log("");
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Migrate
114
+ // ---------------------------------------------------------------------------
115
+
116
+ let totalDocsChanged = 0;
117
+ let totalConverted = 0;
118
+ let totalLeftAbsolute = 0;
119
+ const projectsWithoutRoot = [];
120
+
121
+ for (const project of projects) {
122
+ const root = getCodeRoot(project);
123
+ if (!root) {
124
+ projectsWithoutRoot.push(project);
125
+ continue;
126
+ }
127
+
128
+ const projectDir = path.join(dataDir, "projects", project);
129
+ const files = walkMdFiles(projectDir);
130
+ if (files.length === 0) continue;
131
+
132
+ const docChanges = [];
133
+
134
+ for (const filePath of files) {
135
+ const fullId = filePathToDocId(filePath, projectDir);
136
+ const parsed = matter(fs.readFileSync(filePath, "utf-8"));
137
+ const tracked = parsed.data.tracked_files;
138
+ if (!Array.isArray(tracked) || tracked.length === 0) continue;
139
+
140
+ let docChanged = false;
141
+ for (const tf of tracked) {
142
+ if (!tf || typeof tf.path !== "string") continue;
143
+ if (!path.isAbsolute(tf.path)) continue; // already relative
144
+
145
+ const stored = toStoredPath(tf.path, project);
146
+ if (path.isAbsolute(stored)) {
147
+ // Outside the root (or different drive) — resolver kept it absolute.
148
+ totalLeftAbsolute++;
149
+ docChanges.push(` [${fullId}] KEPT ABSOLUTE (outside root): ${tf.path}`);
150
+ continue;
151
+ }
152
+
153
+ docChanges.push(` [${fullId}] "${tf.path}" → "${stored}"`);
154
+ tf.path = stored;
155
+ docChanged = true;
156
+ totalConverted++;
157
+ }
158
+
159
+ if (docChanged) {
160
+ totalDocsChanged++;
161
+ if (!dryRun) {
162
+ fs.writeFileSync(filePath, matter.stringify(parsed.content, parsed.data), "utf-8");
163
+ }
164
+ }
165
+ }
166
+
167
+ if (docChanges.length > 0) {
168
+ console.log(`### Project: ${project} (root: ${root})`);
169
+ docChanges.forEach((c) => console.log(c));
170
+ console.log("");
171
+ }
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Summary
176
+ // ---------------------------------------------------------------------------
177
+
178
+ console.log("─".repeat(60));
179
+ console.log(`Paths converted to relative : ${totalConverted}`);
180
+ console.log(`Paths kept absolute : ${totalLeftAbsolute} ← outside the code root`);
181
+ console.log(`Docs modified : ${totalDocsChanged}`);
182
+ if (projectsWithoutRoot.length > 0) {
183
+ console.log("");
184
+ console.log(`Projects skipped (no code root configured): ${projectsWithoutRoot.length}`);
185
+ console.log(` ${projectsWithoutRoot.join(", ")}`);
186
+ console.log(" → set MCP_DOCUMENTOR_CODE_ROOT or add them to MCP_DOCUMENTOR_CODE_ROOTS to migrate them.");
187
+ }
188
+
189
+ if (dryRun) {
190
+ console.log("");
191
+ console.log("[DRY RUN] No files were modified. Run without --dry-run to apply.");
192
+ }